《A Philosophy of Software Design》软件设计哲学(下)

作者:John Ousterhout(斯坦福大学的 Bosack Lerner 计算机科学教授。他是 Tcl 脚本语言的创建者,并且以在分布式操作系统和存储系统中的工作而闻名。Ousterhout 在耶鲁大学获得了物理学学士学位,并在卡内基梅隆大学获得了计算机科学博士学位。他是美国国家工程院院士,并获得了无数奖项,包括 ACM 软件系统奖,ACM Grace Murray Hopper 奖,美国国家科学基金会总统年轻研究者奖和 UC Berkeley 杰出教学奖。)

这本书是关于如何使用复杂性来指导软件设计的整个生命周期。本文为 11 ~ 21 章笔记,后续会调整内容,给予更贴近前端的案例。

todo:案例替换;文案优化

设计它两次(Design it Twice)

设计软件非常困难,因此你对如何构造模块或系统的初步思考不太可能会产生最佳的设计。如果为每个主要设计决策可以考虑多个选项,最终将获得更好的结果:即“设计两次”。

假设你正在设计用于管理 GUI 文本编辑器文件文本的类。第一步是定义该类将呈现给编辑器其余部分的接口。与其选择想到的第一个想法,不如考虑几种可能性。一种选择是面向行的界面,该界面具有插入,修改和删除整行文本的操作。另一个选择是基于单个字符插入和删除的接口。第三种选择是面向字符串的接口,该接口可对可能跨越线边界的任意范围的字符进行操作。你无需确定每个替代方案的每个功能;在这一点上,勾勒出一些最重要的方法就足够了。

尝试选择彼此根本不同的方法,这样你将学到更多。即使你确定只有一种合理的方法,无论你认为有多糟糕,都应该考虑第二种设计。考虑该设计的弱点并将它们与其他设计的特征进行对比将很有启发性。

在对备选方案进行粗略设计之后,列出每个方案的优缺点。一个接口最重要的考虑因素是高级软件的易用性。在前例中,面向行的界面和面向字符的界面都需要使用文本类的软件中的额外工作。面向行的界面将需要更高级别的软件来在部分行和多行操作(例如剪切和粘贴所选内容)期间拆分和合并行。面向字符的接口将需要循环来实现修改多个字符的操作。还值得考虑其他因素:

  • 一种选择是否具有比另一种更简单的界面?在文本示例中,所有文本界面都相对简单。
  • 一个接口比另一个接口更通用吗?
  • 一个接口是否比另一个接口更有效地实现?在文本示例中,面向字符的方法可能比其他方法慢得多,因为它需要为每个字符单独调用文本模块。

比较了备选设计之后,你将可以更好地确定最佳设计。最佳选择可能是这些选择之一,或者你可能发现可以将多个选择的功能组合到一个比任何原始选择都要好的新设计中。

有时,没有其他选择特别有吸引力。发生这种情况时,请查看是否可以提出其他方案。使用你在原始替代方案中发现的问题来推动新设计。如果你在设计文本类并且仅考虑面向行和面向字符的方法,则可能会注意到每个替代方案都比较笨拙,因为它需要更高级别的软件来执行其他文本操作。那是一个危险信号:如果要有一个文本类,它应该处理所有文本操作。为了消除其他文本操作,文本界面需要更紧密地匹配高级软件中发生的操作。这些操作并不总是对应于单个字符或一行。

两次设计原则可以在系统的许多级别上应用。对于模块,你可以首先使用此方法来选择接口,如上所述。然后,你可以在设计实现时再次应用它:对于文本类,你可以考虑实现这些实现,例如行的链接列表,固定大小的字符块或“间隙缓冲区”。实现的目标与接口的目标是不同的:对于实现,最重要的是简单性和性能。在系统的更高层次上探索多种设计也很有用,例如在为用户界面选择功能或将系统分解为主要模块时。在每种情况下,如果你可以比较几种选择,则更容易确定最佳方法。

对其进行两次设计不需要花费很多额外的时间。对于较小的模块(如课程),你可能不需要一两个小时就能考虑替代方法。与你将花费数天或数周时间来实施该课程相比,这是很少的时间。最初的设计实验可能会导致明显更好的设计,这将比花两次设计时间所花的时间多。对于较大的模块,你将花费更多的时间进行初始设计探索,但是实现也将花费更长的时间,并且更好的设计所带来的好处也会更高。

我已经注意到,真正聪明的人有时很难接受两次设计原则。当他们长大后,聪明的人会发现,他们对任何问题的第一个快速构想就足以取得良好的成绩。无需考虑第二种或第三种可能性。这使得容易养成不良的工作习惯。但是,随着这些人变老,他们将被提升到越来越困难的环境中。最终,每个人 ​​ 都达到了你的第一个想法不再足够好的地步。如果你想获得非常好的结果,那么无论你多么聪明,都必须考虑第二种可能性,或者第三种可能性。大型软件系统的设计属于此类:没有人能很好地在首次尝试时就将其正确。

不幸的是,我经常看到聪明的人坚持要实现第一个想到的想法,这会使他们无法发挥其真正的潜力(这也使他们沮丧地工作)。也许他们下意识地相信“聪明的人第一次就能做到”,因此,如果他们尝试多种设计,那将意味着他们毕竟并不聪明。不是这种情况。不是说你不聪明;问题真的很难解决!此外,这是一件好事:处理一个必须认真思考的难题比处理一个根本不需要思考的难题更有趣。

“两次设计”方法不仅可以改善你的设计,而且可以提高你的设计技能。设计和比较多种方法的过程将教你使设计更好或更坏的因素。随着时间的流逝,这将使你更容易排除不良的设计并磨练真正的出色设计。

为什么写注释?四个理由(Why Write Comments? The Four Excuses)

代码内文档在软件设计中起着至关重要的作用。注释对于帮助开发人员理解系统和有效工作至关重要,但是注释的作用不止于此。文档在抽象中也起着重要作用。没有注释,你就无法隐藏复杂性。最后,编写注释的过程(如果正确完成)将实际上改善系统的设计。相反,如果没有很好的文档记录,那么好的软件设计会失去很多价值。

不幸的是,这种观点并未得到普遍认同。生产代码(编译/构建前)的很大一部分基本上不包含任何注释。许多开发人员认为注释是浪费时间。其他人则看到了注释中的价值,但不知何故从不动手编写它们。幸运的是,许多开发团队认识到了文档的价值,并且感觉这些团队的普及率正在逐渐提高。但是,即使在鼓励文档的团队中,注释也经常被视为繁琐的工作,而且许多开发人员也不了解如何编写注释,因此生成的文档通常是平庸的。文档不足会给软件开发带来巨大且不必要的拖累。

好的注释可以对软件的整体质量产生很大的影响;写好注释并不难;并且(可能很难相信)写注释实际上很有趣。

当开发人员不写注释时,他们通常会以以下一种或多种借口为自己的行为辩护:

  • “好的代码可以自我记录。”
  • “我没有时间写注释。”
  • “注释过时,并会产生误导。”
  • “我所看到的注释都是毫无价值的;何必?” 在以下各节中,我将依次讨论这些借口。

Good code is self-documenting 好的代码可以自我记录

有人认为,如果代码编写得当,那么显而易见,不需要注释。这是一个美味的神话,就像谣言说冰淇淋对你的健康有益:我们真的很想相信!不幸的是,事实并非如此。可以肯定的是,在编写代码时可以做一些事情来减少对注释的需求,例如选择好的变量名。尽管如此,仍有大量设计信息无法用代码表示。例如,只能在代码中正式指定类接口的一小部分,例如其方法的签名。接口的非正式方面,例如对每种方法的作用或其结果含义的高级描述,只能在注释中描述。

一些开发人员认为,如果其他人想知道某个方法的作用,那么他们应该只阅读该方法的代码:这将比任何注释都更准确。读者可能会通过阅读其代码来推断该方法的抽象接口,但这既费时又痛苦。另外,如果在编写代码时期望用户会阅读方法实现,则将尝试使每个方法尽可能短,以便于阅读。如果该方法执行了一些重要操作,则将其分解为几个较小的方法。这将导致大量浅层方法。此外,它并没有真正使代码更易于阅读:为了理解顶层方法的行为,读者可能需要了解嵌套方法的行为。

此外,注释是抽象的基础。抽象的目的是隐藏复杂性:抽象是实体的简化视图,该实体保留必要的信息,但忽略了可以忽略的细节。如果用户必须阅读方法的代码才能使用它,则没有任何抽象:方法的所有复杂性都将暴露出来。没有注释,方法的唯一抽象就是其声明,该声明指定其名称以及其参数和结果的名称和类型。该声明缺少太多基本信息,无法单独提供有用的抽象。例如,提取子字符串的方法可能有两个参数,开始和结束,表示要提取的字符范围。仅凭宣言,无法确定提取的子字符串是否将包含 end 指示的字符,或者如果 start > end 会发生什么。注释使我们能够捕获调用者所需的其他信息,从而在隐藏实现细节的同时完成简化的视图。用人类语言(例如英语)写注释也很重要;这使它们不如代码精确,但提供了更多的表达能力,因此我们可以创建简单直观的描述。如果要使用抽象来隐藏复杂性,则注释必不可少。

I don’t have time to write comments 我没有时间写注释

优先考虑低于其他开发任务的注释是很诱人的。在添加新功能和记录现有功能之间做出选择之后,选择新功能似乎合乎逻辑。但是,软件项目几乎总是处于时间压力之下,并且总会有比编写注释优先级更高的事情。因此,如果你允许取消对文档的优先级,则最终将没有文档。

与该借口相反的是投资思路。如果你想要一个干净的软件结构,可以长期有效地工作,那么你必须花一些额外的时间才能创建该结构。好的注释对软件的可维护性有很大的影响,因此花费在它们上面的精力将很快收回成本。此外,撰写注释不需要花费很多时间。询问自己,假设你不包含任何注释,那么你花费了多少开发时间来键入代码(与设计,编译,测试等相对)。我怀疑答案是否超过 10%。现在假设你花在输入注释上的时间与输入代码所花费的时间一样多。这应该是一个安全的上限。基于这些假设,撰写好的注释不会增加你的开发时间约 10%。拥有良好文档的好处将迅速抵消这一成本。

此外,许多最重要的注释是与抽象有关的注释,例如类和方法的顶级文档。第 15 章认为,这些注释应作为设计过程的一部分编写,并且编写文档的行为是改善整体设计的重要设计工具。这些注释立即付诸行动。

Comments get out of date and become misleading 注释过时并产生误导

注释有时确实会过时,但这实际上并不是主要问题。使文档保持最新状态并不需要付出巨大的努力。仅当对代码进行了较大的更改时才需要对文档进行大的更改,并且代码更改将比文档的更改花费更多的时间。后面(第 16 章)讨论了如何组织文档,以便在修改代码后尽可能容易地对其进行更新(主要思想是避免重复的文档并使文档与相应的代码保持一致)。代码审查提供了一种检测和修复陈旧注释的强大机制。

All the comments I have seen are worthless 我所看到的所有注释都是毫无价值的

在这四个借口中,这可能是最有价值的借口。每个软件开发人员都看到没有提供有用信息的注释,并且大多数现有文档充其量都是这样。幸运的是,这个问题是可以解决的。一旦知道了如何编写可靠的文档并不难。

Benefits of well-written comments 编写良好的评论的好处

既然我已经讨论了(并希望揭穿了这些)反对撰写注释的论点,让我们考虑一下从良好注释中将获得的好处。注释背后的总体思想是捕获设计者所想但不能在代码中表示的信息。这些信息从低级详细信息(例如,激发特殊代码的硬件怪癖)到高级概念(例如,类的基本原理)。当其他开发人员稍后进行修改时,这些注释将使他们能够更快,更准确地工作。没有文档,未来的开发人员将不得不重新编写或猜测开发人员的原始知识。这将花费额外的时间,并且如果新开发者误解了原始设计者的意图,则存在错误的风险。

回顾此前在软件系统中表现出复杂性的三种方式:

  • 变更放大:看似简单的变更需要在许多地方进行代码修改。
  • 认知负荷:为了进行更改,开发人员必须积累大量信息。
  • 未知未知数:尚不清楚需要修改哪些代码,或必须考虑哪些信息才能进行这些修改。

好的文档可以帮助解决最后两个问题。通过为开发人员提供他们进行更改所需的信息,并使开发人员容易忽略不相关的信息,文档可以减轻认知负担。没有足够的文档,开发人员可能必须阅读大量代码才能重构设计人员的想法。文档还可以通过阐明系统的结构来减少未知的未知数,从而可以清楚地了解与任何给定更改相关的信息和代码。

并且此前(第二章)指出,导致复杂性的主要原因是依赖性和模糊性。好的文档可以阐明依赖关系,并且可以填补空白以消除模糊性。

注释应该描述代码中不明显的内容(Comments Should Describe Things that Aren’t Obvious from the Code)

编写注释的原因是,使用编程语言编写的语句无法捕获编写代码时开发人员想到的所有重要信息。注释记录了这些信息,以便后来的开发人员可以轻松地理解和修改代码。注释的指导原则是,注释应描述代码中不明显的内容。

从代码中看不到很多事情。有时,底层细节并不明显。例如,当一对索引描述一个范围时,由索引给出的元素是在范围之内还是之外并不明显。有时不清楚为什么需要代码,或者为什么要以特定方式实现代码。有时,开发人员遵循一些规则,例如“总是在 b 之前调用 a”。你可能可以通过查看所有代码来猜测规则,但这很痛苦且容易出错。注释可以使规则清晰明了。

注释的最重要原因之一是抽象,其中包括许多从代码中看不到的信息。抽象的思想是提供一种思考问题的简单方法,但是代码是如此详细,以至于仅通过阅读代码就很难看到抽象。注释可以提供一个更简单,更高级的视图(“调用此方法后,网络流量将被限制为每秒 maxBandwidth 字节”)。即使可以通过阅读代码推断出此信息,我们也不想强迫模块用户这样做:阅读代码很耗时,并且迫使他们考虑很多不需要使用的信息模块。开发人员应该能够理解模块提供的抽象,而无需阅读其外部可见声明以外的任何代码。

Pick conventions 选择约定

编写注释的第一步是确定注释的约定,例如你要注释的内容和注释的格式。如果你正在使用存在文档编译工具的语言进行编程,例如 Java 的 Javadoc,C ++的 Doxygen 或 Go!的 godoc,请遵循工具的约定。这些约定都不是完美的,但是这些工具可提供足够的好处来弥补这一缺点。如果在没有现有约定可遵循的环境中进行编程,请尝试从其他类似的语言或项目中采用这些约定;这将使其他开发人员更容易理解和遵守你的约定。

约定有两个目的。

  • 首先,它们确保一致性,这使得注释更易于阅读和理解。
  • 其次,它们有助于确保你实际编写评论。如果你不清楚要发表的评论以及发表评论的方式,那么很容易最终根本不发表评论。

大多数注释属于以下类别之一:

  • 接口:在模块声明(例如类,数据结构,函数或方法)之前的注释块。注释描述模块的接口。对于一个类,注释描述了该类提供的整体抽象。对于方法或函数,注释描述其整体行为,其参数和返回值(如果有),其生成的任何副作用或异常,以及调用者在调用该方法之前必须满足的任何其他要求。
  • 数据结构成员:数据结构中字段声明旁边的注释,例如类的实例变量或静态变量。
  • 实现注释:方法或函数代码内部的注释,它描述代码在内部的工作方式。
  • 跨模块注释:描述跨模块边界的依赖项的注释。

最重要的注释是前两个类别中的注释。每个类都应有一个接口注释,每个类变量应有一个注释,每个方法都应有一个接口注释。有时,变量或方法的声明是如此明显,以至于在注释中没有添加任何有用的东西(getter 和 setter 有时都属于此类),但这很少见。评论所有内容要比花精力担心是否需要评论要容易得多。实施注释通常是不必要的。跨模块注释是最罕见的,而且编写起来很成问题,但是当需要它们时,它们就很重要

Don’t repeat the code 不要重复代码

不幸的是,许多注释并不是特别有用。最常见的原因是注释重复了代码:可以轻松地从注释旁边的代码中推断出注释中的所有信息。

如:

1
2
3
4
5
6
7
8
9
10
11
12
// Add a horizontal scroll bar
hScrollBar = new JScrollBar(JScrollBar.HORIZONTAL);
add(hScrollBar, BorderLayout.SOUTH);

// Add a vertical scroll bar
vScrollBar = new JScrollBar(JScrollBar.VERTICAL);
add(vScrollBar, BorderLayout.EAST);

// Initialize the caret-position related values
caretX = 0;
caretY = 0;
caretMemX = null;

这些注释均未提供任何价值。对于前两个注释,代码已经很清楚了,它实际上不需要注释。在第三种情况下,注释可能有用,但是当前注释没有提供足够的细节来提供帮助。

编写注释后,请问自己以下问题:从未看过代码的人能否仅通过查看注释旁边的代码来编写注释?如果答案是肯定的(如上述示例所示),则注释不会使代码更易于理解。像这样的注释是为什么有些人认为毫无价值的原因。

另一个常见的错误是在注释中使用与要记录的实体名称相同的词:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* Obtain a normalized resource name from REQ.
*/
private static String[] getNormalizedResourceNames(
HTTPRequest req) ...
/*
* Downcast PARAMETER to TYPE.
*/
private static Object downCastParameter(String parameter, String type) ...
/*
* The horizontal padding of each line in the text.
*/
private static final int textHorizontalPadding = 4;

这些注释只是从方法或变量名中提取单词,或者从参数名称和类型中添加几个单词,然后将它们组成一个句子。例如,第二个注释中唯一不在代码中的是单词“ to”!再说一次,这些注释可以仅通过查看声明来编写,而无需任何了解变量的方法。结果,它们没有价值。

如果注释旁边的代码中的注释信息已经很明显,则注释无济于事。这样的一个例子是,当注释使用与所描述事物名称相同的单词时。

同时,注释中缺少一些重要信息:例如,什么是“标准化资源名称”,以及 getNormalizedResourceNames 返回的数组的元素是什么?“贬低”是什么意思?填充的单位是什么,填充是在每行的一侧还是在两者的两侧?在注释中描述这些内容将很有帮助。

编写良好注释的第一步是在注释中使用与所描述实体名称不同的词。为注释选择单词,以提供有关实体含义的更多信息,而不仅仅是重复其名称。例如,以下是针对 textHorizo​​ntalPadding 的更好注释:

1
2
3
4
5
/*
* The amount of blank space to leave on the left and
* right sides of each line of text, in pixels.
*/
private static final int textHorizontalPadding = 4;

该注释提供了从声明本身不明显的其他信息,例如单位(像素)以及填充适用于每行两边的事实。如果读者不熟悉该术语,则注释将解释什么是填充,而不是使用术语“填充”。

Lower-level comments add precision 低级注释可提高精度

现在你知道了不应该做的事情,让我们讨论应该在注释中添加哪些信息。注释通过提供不同详细程度的信息来增强代码。一些注释提供了比代码更低,更详细的信息。这些注释通过阐明代码的确切含 ​​ 义来增加精度。其他注释提供了比代码更高,更抽象的信息。这些注释提供了直觉,例如代码背后的推理,或者更简单,更抽象的代码思考方式。与代码处于同一级别的注释可能会重复该代码。本节将更详细地讨论下层方法,而下一节将讨论上层方法。

在注释变量声明(例如类实例变量,方法参数和返回值)时,精度最有用。变量声明中的名称和类型通常不是很精确。注释可以填写缺少的详细信息,例如:

  • 此变量的单位是什么?
  • 边界条件是包容性还是排他性?
  • 如果允许使用空值,则意味着什么?
  • 如果变量引用了最终必须释放或关闭的资源,那么谁负责释放或关闭该资源?
  • 是否存在某些对于变量始终不变的属性(不变量),例如“此列表始终包含至少一个条目”?

通过检查使用该变量的所有代码,可以潜在地了解某些信息。但是,这很耗时且容易出错。声明的注释应清晰,完整,以免不必要。当我说声明的注释应描述代码中不明显的内容时,“代码”是指注释(声明)旁边的代码,而不是“应用程序中的所有代码”。

变量注释最常见的问题是注释太模糊。这是两个不够精确的注释示例:

1
2
3
4
5
6
// Current offset in resp Buffer
uint32_t offset;

// Contains all line-widths inside the document and
// number of appearances.
private TreeMap<Integer, Integer> lineWidths;

在第一个示例中,尚不清楚“当前”的含义。在第二个示例中,尚不清楚 TreeMap 中的键是线宽,值是出现次数。另外,宽度是以像素或字符为单位测量的吗?以下修订后的注释提供了更多详细信息:

1
2
3
4
5
6
7
8
9
10
//  Position in this buffer of the first object that hasn't
// been returned to the client.
uint32_t offset;

// Holds statistics about line lengths of the form <length, count>
// where length is the number of characters in a line (including
// the newline), and count is the number of lines with
// exactly that many characters. If there are no lines with
// a particular length, then there is no entry for that length.
private TreeMap<Integer, Integer> numLinesWithLength;

第二个声明使用一个较长的名称来传达更多信息。它还将“宽度”更改为“长度”,因为该术语更可能使人们认为单位是字符而不是像素。请注意,第二条注释不仅记录了每个条目的详细信息,还记录了缺少条目的含义。

在记录变量时,请考虑名词而不是动词。换句话说,关注变量代表什么,而不是如何操纵变量。考虑以下注释:

1
2
3
4
5
6
/* FOLLOWER VARIABLE: indicator variable that allows the Receiver and the
* PeriodicTasks thread to communicate about whether a heartbeat has been
* received within the follower's election timeout window.
* Toggled to TRUE when a valid heartbeat is received.
* Toggled to FALSE when the election timeout window is reset. */
private boolean receivedValidHeartbeat;

本文档描述了如何通过类中的几段代码来修改变量。如果注释描述变量代表什么而不是镜像代码结构,则注释将更短且更有用:

1
2
3
4
/* True means that a heartbeat has been received since the last time
* the election timer was reset. Used for communication between the
* Receiver and PeriodicTasks threads. */
private boolean receivedValidHeartbeat;

根据本文档,很容易推断出,当接收到心跳信号时,变量必须设置为 true;而当重置选举计时器时,则必须将变量设置为 false。

Higher-level comments enhance intuition 高级注释可增强直觉

注释可以增加代码的第二种方法是提供直觉。这些注释是在比代码更高的级别上编写的。它们忽略了细节,并帮助读者理解了代码的整体意图和结构。此方法通常用于方法内部的注释以及接口注释。例如,考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// If there is a LOADING readRpc using the same session
// as PKHash pointed to by assignPos, and the last PKHash
// in that readRPC is smaller than current assigning
// PKHash, then we put assigning PKHash into that readRPC.
int readActiveRpcId = RPC_ID_NOT_ASSIGNED;
for (int i = 0; i < NUM_READ_RPC; i++) {
if (session == readRpc[i].session
&& readRpc[i].status == LOADING
&& readRpc[i].maxPos < assignPos
&& readRpc[i].numHashes < MAX_PKHASHES_PERRPC) {
readActiveRpcId = i;
break;
}
}

该注释太底层和太详细。一方面,它部分重复了代码:“如果有 LOADING readRPC”仅重复测试 readRpc[i].status == LOADING。另一方面,注释不能解释此代码的总体目的,也不能解释其如何适合包含此代码的方法。如此一来注释不能帮助读者理解代码。

这是一个更好的注释:

1
2
// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.

此注释不包含任何详细信息。相反,它在更高级别上描述了代码的整体功能。有了这些高级信息,读者就可以解释代码中几乎发生的所有事情:循环必须遍历所有现有的远程过程调用(RPC);会话测试可能用于查看特定的 RPC 是否发往正确的服务器;LOADING 测试表明 RPC 可以具有多个状态,在某些状态下添加更多的哈希值是不安全的;MAX-PKHASHES_PERRPC 测试表明在单个 RPC 中可以发送多少个哈希值是有限制的。注释中唯一没有解释的是 maxPos 测试。此外,新注释为读者判断代码提供了基础:它可以完成将密钥哈希添加到现有 RPC 所需的一切吗?原始注释并未描述代码的整体意图,因此,读者很难确定代码是否行为正确。

高级别的注释比低级别的注释更难编写,因为你必须以不同的方式考虑代码。问问自己:这段代码要做什么?你能以何种最简单方式来解释代码中的所有内容?这段代码最重要的是什么?

工程师往往非常注重细节。我们喜欢细节,善于管理其中的许多细节;这对于成为一名优秀的工程师至关重要。但是,优秀的软件设计师也可以从细节退后一步,从更高层次考虑系统。这意味着要确定系统的哪些方面最重要,并且能够忽略底层细节,仅根据系统的最基本特征来考虑系统。这是抽象的本质(找到一种思考复杂实体的简单方法),这也是编写高级注释时必须执行的操作。一个好的高层注释表达了一个或几个简单的想法,这些想法提供了一个概念框架,例如“附加到现有的 RPC”。使用该框架,可以很容易地看到特定的代码语句与总体目标之间的关系。

这是另一个代码示例,具有较高层次的注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (numProcessedPKHashes < readRpc[i].numHashes) {
// Some of the key hashes couldn't be looked up in
// this request (either because they aren't stored
// on the server, the server crashed, or there
// wasn't enough space in the response message).
// Mark the unprocessed hashes so they will get
// reassigned to new RPCs.
for (size_t p = removePos; p < insertPos; p++) {
if (activeRpcId[p] == i) {
if (numProcessedPKHashes > 0) {
numProcessedPKHashes--;
} else {
if (p < assignPos)
assignPos = p;
activeRpcId[p] = RPC_ID_NOT_ASSIGNED;
}
}
}
}

此注释有两件事。第二句话提供了代码功能的抽象描述。第一句话是不同的:它以高级的方式解释了为什么执行代码。“如何到达这里”形式的注释对于帮助人们理解代码非常有用。例如,在记录方法时,描述最有可能在什么情况下调用该方法的条件(特别是仅在异常情况下调用该方法的情况)会非常有帮助。

Interface documentation 接口文档

注释最重要的作用之一就是定义抽象。抽象是实体的简化视图,它保留了基本信息,但省略了可以安全忽略的细节。代码不适合描述抽象;它的级别太低,它包含实现细节,这些细节在抽象中不应该看到。描述抽象的唯一方法是使用注释。如果你想要呈现良好抽象的代码,则必须用注释记录这些抽象。

记录抽象的第一步是将接口注释与实现注释分开。接口注释提供了使用类或方法时需要知道的信息。他们定义了抽象。实现注释描述了类或方法如何在内部工作以实现抽象。区分这两种注释很重要,这样接口的用户就不会暴露于实现细节。此外,这两种形式最好有所不同。如果接口注释也必须描述实现,则该类或方法很浅。这意味着撰写注释的行为可以提供有关设计质量的线索。

类的接口注释提供了该类提供的抽象的高级描述,例如:

1
2
3
4
5
6
7
8
9
/**
* This class implements a simple server-side interface to the HTTP
* protocol: by using this class, an application can receive HTTP
* requests, process them, and return responses. Each instance of
* this class corresponds to a particular socket used to receive
* requests. The current implementation is single-threaded and
* processes one request at a time.
*/
public class Http {...}

该注释描述了类的整体功能,没有任何实现细节,甚至没有特定方法的细节。它还描述了该类的每个实例代表什么。最后,注释描述了该类的限制(它不支持从多个线程的并发访问),这对于考虑是否使用它的开发人员可能很重要。

方法的接口注释既包括用于抽象的高层信息,又包括用于精度的低层细节:

  • 注释通常以一两个句子开头,描述调用者感知到的方法的行为。这是更高层次的抽象。
  • 注释必须描述每个参数和返回值(如果有)。这些注释必须非常精确,并且必须描述对参数值的任何约束以及参数之间的依赖关系。
  • 如果该方法有任何副作用,则必须在接口注释中记录这些副作用。副作用是该方法的任何结果都会影响系统的未来行为,但不属于结果的一部分。例如,如果该方法将一个值添加到内部数据结构中,可以通过将来的方法调用来检索该值,则这是副作用。写入文件系统也是一个副作用。
  • 方法的接口注释必须描述该方法可能产生的任何异常。
  • 如果在调用某个方法之前必须满足任何前提条件,则必须对其进行描述(也许必须先调用其他方法;对于二进制搜索方法,必须对要搜索的列表进行排序)。尽量减少前提条件是一个好主意,但是任何保留的条件都必须记录在案。

这是从 Buffer 对象复制数据的方法的接口注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Copy a range of bytes from a buffer to an external location.
*
* \param offset
* Index within the buffer of the first byte to copy.
* \param length
* Number of bytes to copy.
* \param dest
* Where to copy the bytes: must have room for at least
* length bytes.
*
* \return
* The return value is the actual number of bytes copied,
* which may be less than length if the requested range of
* bytes extends past the end of the buffer. 0 is returned
* if there is no overlap between the requested range and
* the actual buffer.
*/

uint32_t
Buffer::copy(uint32_t offset, uint32_t length, void* dest)

此注释的语法(例如\ return)遵循 Doxygen 的约定,该程序从 C / C ++代码中提取注释并将其编译为 Web。注释的目的是提供开发人员调用该方法所需的所有信息,包括特殊情况的处理方式(请注意,此方法如何遵循第 10 章的建议并定义与范围规范相关的任何错误。 )。开发人员不必为了调用它而阅读方法的主体,并且接口注释不提供有关如何实现该方法的信息,例如它如何扫描其内部数据结构以查找所需的数据。

对于更扩展的示例,让我们考虑一个称为 IndexLookup 的类,该类是分布式存储系统的一部分。存储系统拥有一个表集合,每个表包含许多对象。另外,每个表可以具有一个或多个索引;每个索引都基于对象的特定字段提供对表中对象的有效访问。例如,一个索引可以用于根据对象的名称字段查找对象,而另一个索引可以用于根据对象的年龄字段查找对象。使用这些索引,应用程序可以快速提取具有特定名称的所有对象,或具有给定范围内的年龄的所有对象。

IndexLookup 类为执行索引查找提供了一个方便的接口。这是一个如何在应用程序中使用的示例:

1
2
3
4
5
6
7
8
query = new IndexLookup(table, index, key1, key2);
while (true) {
object = query.getNext();
if (object == NULL) {
break;
}
... process object ...
}

应用程序首先构造一个类型为 IndexLookup 的对象,并提供用于选择表,索引和索引内范围的参数(例如,如果索引基于年龄字段,则 key1 和 key2 可以指定为 21 和 65 选择年龄介于这些值之间的所有对象)。然后,应用程序重复调用 getNext 方法。每次调用都返回一个位于所需范围内的对象。一旦返回所有匹配的对象,getNext 将返回 NULL。因为存储系统是分布式的,所以此类的实现有些复杂。表中的对象可以分布在多个服务器上,每个索引也可以分布在一组不同的服务器上。

现在,让我们考虑该类的接口注释中需要包含哪些信息。对于下面给出的每条信息,问自己一个开发人员是否需要知道该信息才能使用该类。

  • IndexLookup 类发送给包含索引和对象的服务器的消息格式。
  • 用于确定特定对象是否在所需范围内的比较功能(使用整数,浮点数或字符串进行比较吗?)。
  • 用于在服务器上存储索引的数据结构。
  • IndexLookup 是否同时向多个服务器发出多个请求。
  • 处理服务器崩溃的机制。

这是 IndexLookup 类的接口注释的原始版本;摘录还包括类定义的几行内容,在注释中进行了引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* This class implements the client side framework for index range
* lookups. It manages a single LookupIndexKeys RPC and multiple
* IndexedRead RPCs. Client side just includes "IndexLookup.h" in
* its header to use IndexLookup class. Several parameters can be set
* in the config below:
* - The number of concurrent indexedRead RPCs
* - The max number of PKHashes a indexedRead RPC can hold at a time
* - The size of the active PKHashes
*
* To use IndexLookup, the client creates an object of this class by
* providing all necessary information. After construction of
* IndexLookup, client can call getNext() function to move to next
* available object. If getNext() returns NULL, it means we reached
* the last object. Client can use getKey, getKeyLength, getValue,
* and getValueLength to get object data of current object.
*/
class IndexLookup {
...
private:
/// Max number of concurrent indexedRead RPCs
static const uint8_t NUM_READ_RPC = 10;
/// Max number of PKHashes that can be sent in one
/// indexedRead RPC
static const uint32_t MAX_PKHASHES_PERRPC = 256;
/// Max number of PKHashes that activeHashes can
/// hold at once.
static const size_t MAX_NUM_PK = (1 << LG_BUFFER_SIZE);
}

在进一步阅读之前,请先查看你是否可以使用此注释确定问题所在。这是我发现的问题:

  • 第一段的大部分与实现有关,而不是接口。举一个例子,用户不需要知道用于与服务器通信的特定远程过程调用的名称。在第一段的后半部分中提到的配置参数都是所有私有变量,它们仅与类的维护者相关,而与类的用户无关。所有这些实现信息都应从注释中省略。
  • 该评论还包括一些显而易见的事情。例如,不需要告诉用户包括 IndexLookup.h:任何编写 C ++代码的人都可以猜测这是必要的。另外,“通过提供所有必要的信息”一词毫无意义,因此可以省略。

对此类的简短注释就足够了(并且更可取):

1
2
3
4
5
6
7
8
9
10
/*
* This class is used by client applications to make range queries
* using indexes. Each instance represents a single range query.
*
* To start a range query, a client creates an instance of this
* class. The client can then call getNext() to retrieve the objects
* in the desired range. For each object returned by getNext(), the
* caller can invoke getKey(), getKeyLength(), getValue(), and
* getValueLength() to get information about that object.
*/

此注释的最后一段不是严格必需的,因为它主要针对单个方法复制了注释中的信息。但是,在类文档中提供示例来说明其方法如何协同工作可能会有所帮助,特别是对于使用模式不明显的深层类尤其如此。注意,新注释未提及 getNext 的 NULL 返回值。此注释无意记录每种方法的每个细节;它只是提供高级信息,以帮助读者了解这些方法如何协同工作以及何时可以调用每种方法。有关详细信息,读者可以参考接口注释中的各个方法。此注释也没有提到服务器崩溃;这是因为此类服务器的用户看不到服务器崩溃(系统会自动从中恢复)。

当接口文档(例如方法的文档)描述了不需要使用要记录的事物的实现详细信息时,就会出现此红色标记。

现在考虑以下代码,该代码显示 IndexLookup 中 isReady 方法的文档的第一版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Check if the next object is RESULT_READY. This function is
* implemented in a DCFT module, each execution of isReady() tries
* to make small progress, and getNext() invokes isReady() in a
* while loop, until isReady() returns true.
*
* isReady() is implemented in a rule-based approach. We check
* different rules by following a particular order, and perform
* certain actions if some rule is satisfied.
*
* \return
* True means the next Object is available. Otherwise, return
* false.
*/
bool IndexLookup::isReady() { ... }

再一次,本文档中的大多数内容,例如对 DCFT 的引用以及整个第二段,都与实现有关,因此不属于此处。这是接口注释中最常见的错误之一。某些实现文档很有用,但应放在方法内部,在该方法中应将其与接口文档明确分开。此外,文档的第一句话是含糊的(RESULT_READY 是什么意思?),并且缺少一些重要信息。最后,无需在此处描述 getNext 的实现。这是注释的更好版本:

1
2
3
4
5
6
7
8
9
10
11
12
/*
* Indicates whether an indexed read has made enough progress for
* getNext to return immediately without blocking. In addition, this
* method does most of the real work for indexed reads, so it must
* be invoked (either directly, or indirectly by calling getNext) in
* order for the indexed read to make progress.
*
* \return
* True means that the next invocation of getNext will not block
* (at least one object is available to return, or the end of the
* lookup has been reached); false means getNext may block.
*/

此注释版本提供了有关“就绪”含义的更精确信息,并且提供了重要信息,如果要继续进行索引检索,则必须最终调用此方法。

Implementation comments: what and why, not how 实现注释:什么以及为什么,而不是如何

实现注释是出现在方法内部的注释,以帮助读者了解它们在内部的工作方式。大多数方法是如此简短,简单,以至于它们不需要任何实现注释:有了代码和接口注释,就很容易弄清楚方法的工作原理。

实现注释的主要目的是帮助读者理解代码在做什么(而不是代码如何工作)。一旦读者知道了代码要做什么,通常就很容易理解代码的工作原理。对于简短的方法,代码只做一件事,该问题已在其接口注释中进行了描述,因此不需要实现注释。较长的方法具有多个代码块,这些代码块作为方法的整体任务的一部分执行不同的操作。在每个主要块之前添加注释,以提供对该块的作用的高级(更抽象)描述。这是一个例子:

1
// Phase 1: Scan active RPCs to see if any have completed.

对于循环,在循环前加一个注释来描述每次迭代中发生的事情是有帮助的:

1
2
3
// Each iteration of the following loop extracts one request from
// the request message, increments the corresponding object, and
// appends a response to the response message.

请注意,此注释如何更抽象和直观地描述循环。它没有详细介绍如何从请求消息中提取请求或对象如何递增。仅对于更长或更复杂的循环才需要循环注释,在这种情况下,循环的作用可能并不明显。许多循环足够短且简单,以至于其行为已经很明显。

除了描述代码在做什么之外,实现注释还有助于解释原因。如果代码中有些棘手的方面从阅读中看不出来,则应将它们记录下来。例如,如果一个错误修复程序需要添加目的不是很明显的代码,请添加注释以说明为什么需要该代码。对于错误修复,其中有写得很好的错误报告来描述问题,该注释可以引用错误跟踪数据库中的问题,而不是重复其所有详细信息(“修复 RAM-436,与 Linux 2.4 中的设备驱动程序崩溃有关。” X”)。开发人员可以在 bug 数据库中查找更多详细信息(这是一个避免注释重复的示例,这将在第 16 章中进行讨论)。

对于更长的方法,为一些最重要的局部变量写注释会很有帮助。但是,如果大多数局部变量具有好名字,则不需要文档。如果变量的所有用法在几行之内都是可见的,则通常无需注释即可轻松理解变量的用途。在这种情况下,可以让读者阅读代码来弄清楚变量的含义。但是,如果在大量代码中使用了该变量,则应考虑添加注释以描述该变量。在记录变量时,应关注变量表示的内容,而不是代码中如何对其进行操作。

Cross-module design decisions 跨模块设计决策

在理想环境中,每个重要的设计决策都将封装在一个类中。不幸的是,真实的系统不可避免地最终会影响到多个类的设计决策。例如,网络协议的设计将影响发送方和接收方,并且它们可以在不同的地方实现。跨模块决策通常是复杂而微妙的,并且会导致许多错误,因此,为它们提供良好的文档至关重要。

跨模块文档的最大挑战是找到一个放置它的位置,以便开发人员自然地发现它。有时,放置此类文档的中心位置很明显。例如,RAMCloud 存储系统定义一个状态值,每个请求均返回该状态值以指示成功或失败。为新的错误状况添加状态需要修改许多不同的文件(一个文件将状态值映射到异常,另一个文件为每个状态提供人类可读的消息,依此类推)。幸运的是,添加新的状态值(即 Status 枚举的声明)时,开发人员必须去一个明显的地方。我们通过在该枚举中添加注释来标识所有其他必须修改的地方,从而利用了这一点:在理想环境中,每个重要的设计决策都将封装在一个类中。不幸的是,真实的系统不可避免地最终会影响到多个类的设计决策。例如,网络协议的设计将影响发送方和接收方,并且它们可以在不同的地方实现。跨模块决策通常是复杂而微妙的,并且会导致许多错误,因此,为它们提供良好的文档至关重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
typedef enum Status {
STATUS_OK = 0,
STATUS_UNKNOWN_TABLET = 1,
STATUS_WRONG_VERSION = 2,
...
STATUS_INDEX_DOESNT_EXIST = 29,
STATUS_INVALID_PARAMETER = 30,
STATUS_MAX_VALUE = 30,
// Note: if you add a new status value you must make the following
// additional updates:
// (1) Modify STATUS_MAX_VALUE to have a value equal to the
// largest defined status value, and make sure its definition
// is the last one in the list. STATUS_MAX_VALUE is used
// primarily for testing.
// (2) Add new entries in the tables "messages" and "symbols" in
// Status.cc.
// (3) Add a new exception class to ClientException.h
// (4) Add a new "case" to ClientException::throwException to map
// from the status value to a status-specific ClientException
// subclass.
// (5) In the Java bindings, add a static class for the exception
// to ClientException.java
// (6) Add a case for the status of the exception to throw the
// exception in ClientException.java
// (7) Add the exception to the Status enum in Status.java, making
// sure the status is in the correct position corresponding to
// its status code.
}

新状态值将添加到现有列表的末尾,因此注释也将放置在最有可能出现的末尾。

不幸的是,在许多情况下,并没有一个明显的中心位置来放置跨模块文档。RAMCloud 存储系统中的一个例子是处理僵尸服务器的代码,僵尸服务器是系统认为已经崩溃但实际上仍在运行的服务器。中和 zombie server 需要几个不同模块中的代码,这些代码都相互依赖。没有一段代码明显是放置文档的中心位置。一种可能性是在每个依赖文档的位置复制文档的部分。然而,这是令人尴尬的,并且随着系统的发展,很难使这样的文档保持最新。或者,文档可以位于需要它的位置之一,但是在这种情况下,开发人员不太可能看到文档或者知道在哪里查找它。

我最近一直在尝试一种方法,该方法将跨模块问题记录在一个名为 designNotes 的中央文件中。该文件分为清楚标记的部分,每个主要主题一个。例如,以下是该文件的摘录:

1
2
3
4
5
6
7
8
9
10
11
12
13
...
Zombies
-------
A zombie is a server that is considered dead by the rest of the
cluster; any data stored on the server has been recovered and will
be managed by other servers. However, if a zombie is not actually
dead (e.g., it was just disconnected from the other servers for a
while) two forms of inconsistency can arise:
* A zombie server must not serve read requests once replacement servers have taken over; otherwise it may return stale data that does not reflect writes accepted by the replacement servers.
* The zombie server must not accept write requests once replacement servers have begun replaying its log during recovery; if it does, these writes may be lost (the new values may not be stored on the replacement servers and thus will not be returned by reads).

RAMCloud uses two techniques to neutralize zombies. First,
...

然后,在与这些问题之一相关的任何代码段中,都有一条简短的注释引用了 designNotes 文件:

1
// See "Zombies" in designNotes.

使用这种方法,文档只有一个副本,因此开发人员在需要时可以相对容易地找到它。但是,这样做的缺点是,文档离它依赖的任何代码段都不近,因此随着系统的发展,可能难以保持最新。

注释的目的是确保系统的结构和行为对读者来说是显而易见的,因此他们可以快速找到所需的信息,并有信心对其进行修改,以对系统进行修改。这些信息中的某些信息可以以对读者来说显而易见的方式表示在代码中,但是有大量信息无法从代码中轻易推导出。注释将填写此信息。

当遵循注释应描述代码中不明显的内容的规则时,“明显”是从第一次读取你的代码的人(不是你)的角度出发。在撰写注释时,请尝试使自己进入读者的心态,并问自己他或她需要知道哪些关键事项。如果你的代码正在接受审核,并且审核者告诉你某些不明显的内容,请不要与他们争论。如果读者认为它不明显,那么它就不明显。不用争论,而是尝试了解他们发现的令人困惑的地方,并查看是否可以通过更好的注释或更好的代码来澄清它们。

*开发人员是否需要了解以下每条信息才能使用 IndexLookup 类?

  • IndexLookup 类发送给包含索引和对象的服务器的消息格式。否:这是应隐藏在类中的实现细节。
  • 用于确定特定对象是否在所需范围内的比较功能(使用整数,浮点数或字符串进行比较吗?)。是:该课程的用户需要了解此信息。
  • 用于在服务器上存储索引的数据结构。否:此信息应封装在服务器上;甚至 IndexLookup 的实现都不需要知道这一点。
  • IndexLookup 是否同时向多个服务器发出多个请求。可能:如果 IndexLookup 使用特殊技术来提高性能,则文档应提供有关此问题的一些高级信息,因为用户可能会在意性能。
  • 处理服务器崩溃的机制。否:RAMCloud 可从服务器崩溃中自动恢复,因此崩溃对于应用程序级软件不可见;因此,在 IndexLookup 的接口文档中无需提及崩溃。如果崩溃反映到应用程序中,则接口文档将需要描述它们如何表现出来(而不是崩溃恢复如何工作的详细信息)。

选择的名字(Choosing Names)

为变量,方法和其他实体选择名称是软件设计中被低估的方面之一。良好的名字是一种文档形式:它们使代码更易于理解。它们减少了对其他文档的需求,并使检测错误更加容易。相反,名称选择不当会增加代码的复杂性,并造成可能导致错误的歧义和误解。名称选择是复杂度是递增的原理的一个示例。为特定变量选择一个平庸的名称,而不是最好的名称,这可能不会对系统的整体复杂性产生太大影响。但是,软件系统具有数千个变量。为所有这些选择好名字将对复杂性和可管理性产生重大影响。

名称错误会导致错误

有时,即使是一个名称不正确的变量也会产生严重的后果。

如果对不同种类的块(例如 fileBlock 和 diskBlock)使用了不同的变量名,则错误不太可能发生;程序员会知道在那种情况下不能使用 fileBlock。不幸的是,大多数开发人员没有花太多时间在思考名字。他们倾向于使用想到的名字,只要它与匹配的名字相当接近即可。例如,块与磁盘上的物理块(physical block)和文件内的逻辑块( logical block)非常接近;这肯定不是一个可怕的名字。即使这样,它仍然要花费大量时间来查找一个细微的错误。因此,你不应该只选择“合理接近”的名称。花一些额外的时间来选择准确,明确且直观的好名字。额外的注意力将很快收回成本,随着时间的流逝,你将学会快速选择好名字。

Create an image 创建图像

选择名称时,目标是在读者的脑海中创建一幅关于被命名事物的性质的图像。一个好名字传达了很多有关底层实体是什么,以及同样重要的是,不是什么的信息。在考虑特定名称时,请问自己:“如果有人孤立地看到该名称,而没有看到其声明,文档或使用该名称的任何代码,他们将能够猜到该名称指的是什么?还有其他名称可以使画面更清晰吗?” 当然,一个名字可以输入多少信息是有限制的。如果名称包含两个或三个以上的单词,则会变得笨拙。因此,面临的挑战是仅找到捕获实体最重要方面的几个单词。

名称是一种抽象形式:名称提供了一种简化的方式来考虑更复杂的基础实体。像其他形式的抽象一样,最好的名字是那些将注意力集中在对底层实体最重要的东西上,而忽略那些次要的细节。

Names should be precise 名称应准确

良好名称具有两个属性:精度和一致性。让我们从精度开始。名称最常见的问题是名称太笼统或含糊不清。结果,读者很难说出这个名字指的是什么。读者可能会认为该名称所指的是与现实不符的事物,如上面的代码错误所示。考虑以下方法声明:

1
2
3
4
/**
* Returns the total number of indexlets this object is managing.
*/
int IndexletManager::getCount() {...}

术语“计数”太笼统了:计数什么?如果有人看到此方法的调用,除非他们阅读了它的文档,否则他们不太可能知道它的作用。像 getActiveIndexlets 或 numIndexlets 这样的更精确的名称会更好:使用这些名称之一,读者可能无需查看其文档就能猜测该方法返回的内容。

以下是来自其他项目的一些名称不够精确的示例:

  • 建立 GUI 文本编辑器的项目使用名称 x 和 y 来引用字符在文件中的位置。这些名称太笼统了。他们可能意味着很多事情;例如,它们也可能代表屏幕上字符的坐标(以像素为单位)。单独看到名称 x 的人不太可能会认为它是指字符在一行文本中的位置。如果使用诸如 charIndex 和 lineIndex 之类的名称来反映代码实现的特定抽象,该代码将更加清晰。
  • 另一个编辑器项目包含以下代码:

    1
    2
    // Blink state: true when cursor visible.
    private boolean blinkStatus = true;

    名称 blinkStatus 无法传达足够的信息。“状态”一词对于布尔值来说太含糊了:它不提供关于真值或假值含义的任何线索。“闪烁”一词也含糊不清,因为它并不表示闪烁的内容。以下替代方法更好:

    1
    2
    3
    // Controls cursor blinking: true means the cursor is visible,
    // false means the cursor is not displayed.
    private boolean cursorVisible = true;

名称 cursorVisible 传达了更多信息;例如,它允许读者猜测一个真值的含义(通常,布尔变量的名称应始终为谓词)。名称中不再包含“ blink”一词,因此,如果读者想知道为什么光标不总是可见,则必须查阅文档。此信息不太重要。

  • 一个实施共识协议的项目包含以下代码:

    1
    2
    3
    // Value representing that the server has not voted (yet) for
    // anyone for the current election term.
    private static final String VOTED_FOR_SENTINEL_VALUE = "null";

    此值的名称表示它是特殊的,但没有说明特殊含义是什么。使用更具体的名称(例如 NOT_YET_VOTED)会更好。
    在没有返回值的方法中使用了名为 result 的变量。这个名字有多个问题。首先,它会产生误导性的印象,即它将作为方法的返回值。其次,除了它是一些计算值外,它实际上不提供有关其实际持有内容的任何信息。该名称应提供有关实际结果是什么的信息,例如 mergedLine 或 totalChars。在实际上确实具有返回值的方法中,使用名称结果是合理的。该名称仍然有点通用,但是读者可以查看方法文档以了解其含义,这有助于知道该值最终将成为返回值。
    如果变量或方法的名称足够广泛,可以引用许多不同的事物,那么它不会向开发人员传达太多信息,因此底层实体很可能会被滥用。
    像所有规则一样,有关选择精确名称的规则也有一些例外。例如,只要循环仅跨越几行代码,就可以将通用名称(如 i 和 j)用作循环迭代变量。如果你可以看到一个变量的整个用法范围,那么该变量的含义在代码中就很明显了,因此你不需要长名称。例如,考虑以下代码:

    1
    2
    3
    for  (i = 0; i < numLines; i++) {
    ...
    }

    从这段代码中很明显,i 正被用来迭代某个实体中的每一行。如果循环太长,以至于你无法一次看到全部内容,或者如果很难从代码中找出迭代变量的含义,那么应该使用更具描述性的名称。
    名称也可能太具体,例如在此声明中删除一个文本范围的方法:

    1
    void delete(Range selection) {...}

    参数名称的选择过于具体,因为它建议始终在用户界面中选择要删除的文本。但是,可以在任意范围的文本(无论是否选中)上调用此方法。因此,参数名称应更通用,例如范围。
    如果你发现很难为精确,直观且时间不长的特定变量命名,那么这是一个危险信号。这表明该变量可能没有明确的定义或目的。发生这种情况时,请考虑其他因素。例如,也许你正在尝试使用单个变量来表示几件事;如果是这样,将表示形式分成多个变量可能会导致每个变量的定义更简单。选择好名字的过程可以通过识别弱点来改善你的设计。
    如果很难为创建基础对象清晰图像的变量或方法找到简单的名称,则表明基础对象可能没有简洁的设计。

Use names consistently 一致使用名称

好的名称的第二个重要属性是一致性。在任何程序中,都会反复使用某些变量。例如,文件系统反复操作块号。对于每种常见用法,请选择一个用于该目的的名称,并在各处使用相同的名称。例如,文件系统可能总是使用 fileBlock 来保存文件中块的索引。一致的命名方式与重用普通类的方式一样,可以减轻认知负担:一旦读者在一个上下文中看到了该名称,他们就可以重用其知识并在不同上下文中看到该名称时立即做出假设。

一致性具有三个要求:

  • 首先,始终将通用名称用于给定目的
  • 第二,除了给定目的外,切勿使用通用名称
  • 第三,确保目的足够狭窄,以使所有具有名称的变量都具有相同的行为。

有时你将需要多个变量来引用相同的一般事物。例如,一种复制文件数据的方法将需要两个块号,一个为源,一个为目标。发生这种情况时,请对每个变量使用通用名称,但要添加一个可区分的前缀,例如 srcFileBlock 和 dstFileBlock。

循环是一致性命名可以提供帮助的另一个领域。如果将诸如 i 和 j 之类的名称用于循环变量,则始终在最外层循环中使用 i,而在嵌套循环中始终使用 j。这使读者可以在看到给定名称时对代码中发生的事情做出即时(安全)假设。

A different opinion: Go style guide 不同的意见:Go 样式指南

并非所有人都同意我对命名的看法。一些使用 Go 语言的开发人员认为,名称应该非常简短,通常只能是一个字符。在关于 Go 的名称选择的演示中,Andrew Gerrand 指出“长名称模糊了代码的作用。” 1 他介绍了此代码示例,该示例使用单字母变量名:

1
2
3
4
5
6
7
8
9
10
11
12
13
func RuneCount(b []byte) int {
i, n := 0, 0
for i < len(b) {
if b[i] < RuneSelf {
i++
} else {
_, size := DecodeRune(b[i:])
i += size
}
n++
}
return n
}

并认为它比以下使用更长名称的版本更具可读性:

1
2
3
4
5
6
7
8
9
10
11
12
13
func RuneCount(buffer []byte) int {
index, count := 0, 0
for index < len(buffer) {
if buffer[index] < RuneSelf {
index++
} else {
_, size := DecodeRune(buffer[index:])
index += size
}
count++
}
return count
}

第二版并不比第一版更难读。如果有的话,与 n 相比,名称计数为变量的行为提供了更好的线索。在第一个版本中,我最终通读了代码,试图弄清楚 n 的含义,而第二个版本中我并没有这种需要。但是,如果在整个系统中一致地使用 n 来引用计数(而没有其他内容),那么其他开发人员可能会清楚知道该短名称。

Go 文化鼓励在多个不同的事物上使用相同的短名称:ch 用于字符或通道,d 用于数据,差异或距离,等等。但是像这样的模棱两可的名称很可能导致混乱和错误,就像在示例中一样。

总的来说,我认为可读性必须由读者而不是作家来决定。如果你使用简短的变量名编写代码,并且阅读该代码的人很容易理解,那么很好。如果你开始抱怨代码很含糊,那么你应该考虑使用更长的名称(在网络上搜索“ go language short name”(使用语言简称)会识别出几种此类抱怨)。同样,如果我开始抱怨长变量名使我的代码难以阅读,那么我会考虑使用较短的变量名。

Gerrand 发表一个我同意的评论“名称声明与使用之间的距离越大,名称就应该越长。” 前面有关使用名为 i 和 j 的循环变量的讨论是此规则的示例。

精心选择的名称有助于使代码更明显。当某人第一次遇到该变量时,他们对行为的第一次猜测是正确的。选择好名字是第 3 章讨论的投资思维方式的一个示例:如果你花一些额外的时间来选择好名字,那么将来你将更容易处理代码。此外,你不太可能引入错误。培养命名技巧也是一项投资。当你第一次决定停止为平庸的名字定居时,你会发现想出好名字的过程既令人沮丧又耗时。但是,随着你获得更多的经验,你会发现它变得更加容易。最终,你将几乎不需要花费额外的时间来选择好名字,因此你几乎可以免费获得好处。

先写注释Write The Comments First(Use Comments As Part Of The Design Process)

在完成编码和单元测试之后,许多开发人员推迟编写文档,直到开发过程结束。这是产生质量差的文档的最直接方法之一。编写注释的最佳时间是在过程开始时。首先编写注释使文档成为设计过程的一部分。这不仅可以产生更好的文档,还可以产生更好的设计,并使编写文档的过程更加愉快。

Delayed comments are bad comments 迟到的注释不是好注释

几乎每个开发人员都会推迟编写注释。当被问及为什么不更早编写文档时,他们说代码仍在更改。他们说,如果他们尽早编写文档,则必须在代码更改时重新编写文档。最好等到代码稳定下来。但是,我怀疑还有另一个原因,那就是他们将文档视为繁琐的工作。因此,他们尽可能地推迟了。

不幸的是,这种方法有几个负面影响。首先,延迟文档通常意味着根本无法编写文档。一旦开始延迟,就容易再延迟一些。毕竟,代码将在几周后变得更加稳定。到了代码毫无疑问地稳定下来的时候,代码已经很多了,这意味着编写文档的任务变得越来越庞大,吸引力也越来越小。从来没有一个方便的时间可以停下来几天并填写所有遗漏的注释,并且很容易使该项目的最佳选择合理化,那就是继续并修复错误或编写下一个新功能。这将创建更多未记录的代码。

即使你有自律性回去写注释(不要欺骗你自己:你可能没有),注释也不会很好。在这个过程的这个时候,你已经在精神上离开了。在你的脑海中,这段代码已经完成了;你急于开始下一个项目。你知道写注释是正确的事情,但它没有乐趣。你只想尽快度过难关。因此,你可以快速地浏览代码,添加足够的注释以使其看起来令人满意。到目前为止,你已经有一段时间没有设计代码了,所以你对设计过程的记忆变得模糊了。你在编写注释时查看代码,因此注释重复了代码。即使你试图重构代码中不明显的设计思想,也会有你不记得的事情。因此,这些注释忽略了他们应该描述的一些最重要的事情。

Write the comments first 首先写注释

我使用一种不同的方法来编写注释,在开始时就写注释:

  • 对于新类,我首先编写类接口注释。
  • 接下来,我为最重要的公共方法编写接口注释和签名,但将方法主体保留为空。
  • 我对这些注释进行了迭代,直到基本结构感觉正确为止。
  • 在这一点上,我为类中最重要的类实例变量编写了声明和注释。
  • 最后,我填写方法的主体,并根据需要添加实现注释。
  • 在编写方法主体时,我通常会发现需要其他方法和实例变量。对于每个新方法,我在方法主体之前编写接口注释。例如变量,我在编写变量声明的同时填写了注释。

代码完成后,注释也将完成。从来没有积压的书面注释。

注释优先的方法具有三个好处。首先,它会产生更好的注释。如果你在设计课程时写注释,那么关键的设计问题将在你的脑海中浮现,因此很容易记录下来。最好在每个方法的主体之前编写接口注释,这样你就可以专注于方法的抽象和接口,而不会因其实现而分心。在编码和测试过程中,你会注意到并修复注释问题。结果,注释在开发过程中得到了改善。

Comments are a design tool 注释是一种设计工具

在开始时编写注释的第二个也是最重要的好处是可以改善系统设计。注释提供了完全捕获抽象的唯一方法,好的抽象是好的系统设计的基础。如果你在一开始就写了描述抽象的注释,则可以在编写实现代码之前对其进行检查和调整。要写一个好的注释,你必须确定一个变量或一段代码的本质:这件事最重要的方面是什么?在设计过程的早期进行此操作很重要;否则,你只是在破解代码。

注释是复杂煤矿中的金丝雀。如果方法或变量需要较长的注释,则它是一个危险信号,表明你没有很好的抽象。请记住,类应该很深:最好的类具有非常简单的接口,但可以实现强大的功能。判断接口复杂性的最佳方法是从描述接口的注释中进行。如果某个方法的接口注释提供了使用该方法所需的所有信息,并且又简短又简单,则表明该方法具有简单的接口。相反,如果没有冗长而复杂的注释无法完全描述一个方法,则该方法具有复杂的接口。你可以将方法的接口注释与实现进行比较,以了解该方法的深度:如果接口注释必须描述实现的所有主要功能,则该方法很浅。同样的想法也适用于变量:如果要花很长的时间来完整描述一个变量,那是一个危险信号,表明你可能没有选择正确的变量分解。总体而言,编写注释的行为使你可以及早评估设计决策,以便发现并解决问题。

描述方法或变量的注释应该简单而完整。如果你发现很难写这样的注释,则表明你所描述的内容的设计可能存在问题。

当然,如果注释完整而清晰,那么它们仅是复杂性的良好指标。如果编写的方法接口注释未提供调用该方法所需的全部信息,或者编写的注释过于神秘以至于难以理解,则该注释不能很好地衡量该方法的深度。

Early comments are fun comments 早期注释很有趣

尽早编写注释的第三个也是最后一个好处是,它使编写注释更加有趣。对我来说,编程中最有趣的部分之一是新类的早期设计阶段,在那里,我将充实该类的抽象和结构。我的大部分注释都是在此阶段编写的,这些注释是我记录和测试设计决策质量的方式。我正在寻找可以用最少的词来完整而清晰地表达的设计。注释越简单,我对设计的感觉就越好,因此找到简单的注释是一种自豪感。如果你是策略性编程,而你的主要目标是一个出色的设计,而不仅仅是编写有效的代码,那么编写注释应该很有趣,因为这是你确定最佳设计的方式。

Are early comments expensive? 早期注释是否昂贵?

现在,让我们重新讨论延迟注释的参数,这是因为它避免了在代码演变时重新处理注释的开销。一个简单的信封计算将显示这并不能节省很多。首先,估算你一起键入代码和注释所花费的开发时间的总和,包括修改代码和注释的时间;这不太可能超过所有开发时间的 10%。即使你的全部代码行中有一半是注释,编写注释也可能不会占开发总时间的 5%以上。将注释延迟到最后只会节省其中的一小部分,这不是很多。

首先编写注释将意味着在开始编写代码之前,抽象将更加稳定。这可能会节省编码时间。相反,如果你首先编写代码,则抽象可能会随代码的发展而变化,与注释优先方法相比,将需要更多的代码修订。当你考虑所有这些因素时,可能首先整体编写注释可能会更快。

如果你从未尝试过先编写注释,请尝试一下。坚持足够长的时间来习惯它。然后考虑它如何影响你的注释质量,设计质量以及软件开发的整体乐趣。在尝试了一段时间之后,让我知道你的经历是否与我的相符,以及为什么或为什么不这样。

修改现有的代码Modifying Existing Code

Stay strategic 保持战略

战术方法很快导致系统设计混乱。如果你想要一个易于维护和增强的系统,那么“工作”还不够高。你必须优先考虑设计并进行战略思考。当你修改现有代码时,此想法也适用。

不幸的是,当开发人员进入现有代码以进行更改(例如错误修复或新功能)时,他们通常不会从战略角度进行思考。一个典型的心态是“我能做出我需要做的最小的改变是什么?” 有时,开发人员证明这是合理的,因为他们对修改的代码不满意。他们担心较大的更改会带来更大的引入新错误的风险。但是,这导致了战术编程。这些最小的变化中的每一个都会引入一些特殊情况,依赖性或其他形式的复杂性。结果,系统设计变得更糟,并且问题随着系统发展的每个步骤而累积。

如果要维护系统的简洁设计,则在修改现有代码时必须采取战略性方法。理想情况下,当你完成每次更改时,如果你从一开始就考虑到更改就设计了系统,那么系统将具有它应该具有的结构。为了实现此目标,你必须抵制诱惑以快速解决问题。相反,请根据所需的更改来考虑当前的系统设计是否仍然是最佳的。如果不是,请重构系统,以便最终获得最佳设计。通过这种方法,每次修改都会改善系统设计。

如果你花费一些额外的时间来重构和改善系统设计,你将得到一个更干净的系统。这将加快开发速度,你将收回在重构方面投入的精力。即使你的特定更改不需要重构,你仍然应该注意在代码中可以修复的设计缺陷。每当你修改任何代码时,都尝试在该过程中至少找到一点方法来改进系统设计。如果你没有使设计更好,则可能会使它变得更糟。

如第 3 章所述,投资心态有时与商业软件开发的现实相冲突。如果“正确的方式”重构系统需要三个月,而快速且肮脏的修复仅需两个小时,则你可能必须采取快速而肮脏的方法,尤其是在紧迫的期限内工作时。或者,如果重构系统会造成影响许多其他人员和团队的不兼容性,则重构可能不切实际。

但是,你应尽可能抵制这些妥协。问问自己:“考虑到我目前的限制,这是否是我能做的最好的工作来创建一个干净的系统设计?” 也许有一种替代方法几乎可以像 3 个月的重构一样干净,但是可以在几天内完成?或者,如果你现在负担不起大型重构,请让你的老板为你分配时间,让你在当前截止日期之后恢复到原来的水平。每个开发组织都应计划将其全部工作的一小部分用于清理和重构;从长远来看,这项工作将收回成本。

Maintaining comments: keep the comments near the code 维护注释:将注释保留在代码附近

当你更改现有代码时,更改很有可能会使某些现有注释无效。修改代码时,很容易忘记更新注释,从而导致注释不再准确。不准确的注释使读者感到沮丧,如果注释太多,读者就会开始不信任所有注释。幸运的是,只要有一点纪律和一些指导规则,就可以在不付出巨大努力的情况下使注释保持最新。本节及随后的部分提出了一些特定的技术。

确保注释更新的最佳方法是将注释放置在它们描述的代码附近,以便开发人员在更改代码时可以看到它们。注释离其关联的代码越远,正确更新的可能性就越小。例如,方法接口注释的最佳位置是在代码文件中,紧靠该方法主体的位置。对方法的任何更改都将涉及此代码,因此开发人员很可能会看到接口注释,并在需要时进行更新。

对于 C 和 C ++等具有单独的代码和头文件的语言,一种替代方法是将接口注释放在.h 文件中方法声明的旁边。但是,这距离代码还有很长的路要走。开发人员在修改方法的主体时将看不到这些注释,因此需要打开其他文件并查找接口注释来更新它们,这需要额外的工作。有人可能会争辩说接口注释应该放在头文件中,以便用户可以不必看代码文件就可以学习如何使用抽象。但是,用户无需读取代码或头文件;他们应该从由 Doxygen 或 Javadoc 等工具编译的文档中获取信息。此外,许多 IDE 都会提取文档并将其呈现给用户,例如在键入方法名称时显示方法的文档。给定诸如此类的工具,文档应位于对开发人员进行代码开发最方便的位置。

在编写实现注释时,不要将整个方法的所有注释放在方法的顶部。展开它们,将每个注释推到最狭窄的范围,其中包括该注释所引用的所有代码。例如,如果一种方法具有三个主要阶段,则不要在方法的顶部写一个详细描述所有阶段的注释。而是为每个阶段编写一个单独的注释,并将该注释放置在该阶段的第一行代码的正上方。另一方面,在描述总体策略的方法实现的顶部添加注释也可能会有所帮助,例如:

1
2
3
4
//  We proceed in three phases:
// Phase 1: Find feasible candidates
// Phase 2: Assign each candidate a score
// Phase 3: Choose the best, and remove it

每个阶段的代码上方都可以记录其他详细信息。
通常,注释离描述的代码越远,注释应该越抽象(这减少了注释因代码更改而无效的可能性)。

Comments belong in the code, not the commit log 注释属于代码,而不是提交日志

修改代码时,常见的错误是将有关更改的详细信息放入源代码存储库的提交消息中,而不是将其记录在代码中。尽管将来可以通过扫描存储库的日志来浏览提交消息,但是需要该信息的开发人员不太可能考虑扫描存储库的日志。即使他们确实扫描了日志,也很难找到正确的日志消息。

在编写提交消息时,请问自己将来开发人员是否需要使用该信息。如果是这样,则在代码中记录此信息。一个示例是提交消息,描述了导致代码更改的细微问题。如果代码中未对此进行记录,则开发人员可能会稍后再提出并撤消更改,而不会意识到他们已经重新创建了错误。如果你也想在提交消息中包含此信息的副本,那很好,但是最重要的是在代码中获取它。这说明了将文档放置在开发人员最有可能看到它的地方的原理;提交日志很少在那个地方。

Maintaining comments: avoid duplication 维护注释:避免重复

保持注释最新的第二种技术是避免重复。如果文档重复,那么开发人员将很难找到并更新所有相关副本。相反,请尝试仅一次记录每个设计决策。如果代码中有多个地方受某个特定决定的影响,请不要在所有这些地方重复文档。相反,找到放置文档最明显的位置。例如,假设存在与变量相关的棘手行为,这会影响使用变量的几个不同位置。你可以在变量声明旁边的注释中记录该行为。这是很自然的地方,开发人员可能会检查他们是否在理解使用该变量的代码时遇到麻烦。

如果没有一个“明显的”地方来放置特定的文档,开发人员可以找到它,那么创建一个 designNotes 文件。或者,选择最好的地方,把文档放在那里。另外,在引用中心位置的其他地方添加简短的注释:“查看 xyz 中的注释以了解下面代码的解释。“如果引用因为主注释被移动或删除而变得过时,这种不一致性将是不言而喻的,因为开发人员将无法在指定的位置找到注释;他们可以使用修订控制历史记录来查找注释发生了什么,然后更新引用。相反,如果文档是重复的,并且一些副本没有得到更新,那么开发人员就不会知道他们使用的是陈旧的信息。

不要在另一个模块中记录一个模块的设计决策。例如,不要在方法调用前添加注释,以解释被调用方法中发生的情况。如果读者想知道,他们应该查看该方法的接口注释。好的开发工具通常会自动提供此信息,例如,如果你选择了方法的名称或将鼠标悬停在该方法的名称上,则将显示该方法的接口注释。尝试使开发人员容易找到合适的文档,但是不要重复文档。

如果信息已经在程序之外的某个地方记录了,不要在程序内部重复记录;只需参考外部文档。例如,如果你编写一个实现 HTTP 协议的类,那么就不需要在代码中描述 HTTP 协议。在网上已经有很多关于这个文档的来源;只需在你的代码中添加一个简短的注释,并为其中一个源添加一个 URL。另一个例子是已经在用户手册中记录的特性。假设你正在编写一个实现命令集合的程序,其中有一个负责实现每个命令的方法。如果有描述这些命令的用户手册,就不需要在代码中重复这些信息。相反,在每个命令方法的接口注释中包含如下简短说明:

1
// Implements the Foo command; see the user manual for details.

读者可以轻松找到理解代码所需的所有文档,这一点很重要,但这并不意味着你必须编写所有这些文档。

Maintaining comments: check the diffs 维护注释:检查差异

确保文档保持最新状态的一种好方法是,在将更改提交到修订控制系统之前需要花费几分钟,以扫描该提交的所有更改。确保文档中正确反映了每个更改。这些预先提交的扫描还将检测其他一些问题,例如意外地将调试代码留在系统中或无法修复 TODO 项目。

Higher-level comments are easier to maintain 更高级的注释更易于维护

关于维护文档的最后一个想法:如果注释比代码更高级,更抽象,则注释更易于维护。这些注释不反映代码的详细信息,因此它们不会受到代码更改的影响;只有整体行为的变化才会影响这些评论。当然,正如第 13 章所讨论的那样,某些注释的确需要详细和精确。但总的来说,最有用的注释(它们不只是重复代码)也最容易维护。

一致性Consistency

一致性是降低系统复杂性并使其行为更明显的强大工具。如果系统是一致的,则意味着相似的事情以相似的方式完成,而不同的事情则以不同的方式完成。一致性会产生认知影响力:一旦你了解了某个地方的工作方式,就可以使用该知识立即了解其他使用相同方法的地方。如果系统的实施方式不一致,则开发人员必须分别了解每种情况。这将花费更多时间。

一致性减少了错误。如果系统不一致,则实际上两种情况可能不同,但两种情况可能看起来相同。开发人员可能会看到一个看起来很熟悉的模式,并根据以前对该模式的遭遇做出错误的假设。另一方面,如果系统是一致的,则基于熟悉情况的假设将是安全的。一致性允许开发人员以更少的错误来更快地工作。

Examples of consistency 一致性示例

  • 名字(Names)。此前已经讨论了以一致的方式使用名称的好处。
  • 编码样式(Coding style)。如今,开发组织通常拥有样式指南,这些样式指南将程序结构限制在编译器所强制执行的规则之外。现代风格指南解决了一系列问题,例如缩进,大括号放置,声明顺序,命名,注释以及对认为危险的语言功能的限制。样式指南使代码更易于阅读,并且可以减少某些类型的错误。
  • 接口(Interfaces)。具有多个实现的接口是一致性的另一个示例。一旦了解了接口的一种实现,其他任何实现都将变得更易于理解,因为你已经知道它将必须提供的功能。
  • 设计模式(Design patterns)。设计模式是某些常见问题的普遍接受的解决方案,例如用于用户界面设计的模型视图控制器方法。如果你可以使用现有的设计模式来解决问题,则实现会更快地进行,更有可能起作用,并且你的代码对读者来说也会更明显。
  • 不变量(Invariants)。不变式是始终为真的变量或结构的属性。例如,存储文本行的数据结构可能会强制要求每行以换行符终止。不变式减少了代码中必须考虑的特殊情况的数量,并使推理行为的方式变得更加容易。

Ensuring consistency 确保一致性

一致性很难维护,尤其是当许多人长时间从事一个项目时。一组人可能不了解另一组中建立的约定。新来者不了解规则,因此他们无意间违反了约定并创建了与现有约定冲突的新约定。以下是建立和保持一致性的一些技巧:

  • 文档(Document)。创建一个列出最重要的总体约定的文档,例如编码样式准则。将文档放置在开发人员可能会看到的位置,例如项目 Wiki 上的显眼位置。鼓励新成员加入小组阅读文档,并鼓励现有人员不时审阅该文档。Web 上已经发布了来自各个组织的一些样式指南;考虑从其中之一开始。
    对于局部性更强的约定,例如不变式,请在代码中找到合适的位置进行记录。如果你不写下约定,那么其他人不太可能会遵循它们。
  • 执行(Enforce)。即使有好的文档,开发人员也很难记住所有约定。实施约定的最佳方法是编写一个检查违规的工具,并确保除非通过检查程序,否则代码无法提交到存储库。自动检查器对于底层语法约定特别有用。

我最近的一个项目有行终止字符的问题。一些开发人员在 Unix 上工作,行被换行终止;其他的工作在 Windows 上,行通常由一个 carriage-return 后跟一个换行符来结束。如果一个系统上的开发人员对先前在另一个系统上编辑过的文件进行了小的编辑,那么编辑器有时会将所有行终止符替换为适合该系统的行终止符。这给人的感觉是文件的每一行都被修改了,这使得跟踪有意义的更改变得很困难。我们建立了一个约定,即文件应该只包含换行,但是很难确保每个开发人员使用的每个工具都遵循这个约定。每当一个新的开发人员加入这个项目,我们就会经历一连串的线路终止问题,而那个开发人员就会适应这个约定。

我们最终通过编写一个简短的脚本解决了这个问题,该脚本在更改提交到源代码存储库之前自动执行。该脚本检查所有已修改的文件,如果其中任何一个包含回车符,则中止提交。还可以通过用换行符替换回车/换行符序列来手动运行脚本以修复损坏的文件。这立即消除了问题,并且还帮助培训了新开发人员。

代码审查为实施约定和向新开发者提供有关约定的教育提供了另一个机会。代码审阅者越挑剔,团队中的每个人都将更快地学习约定,并且代码越干净。

在罗马时……最重要的约定是每个开发人员都应遵循古老的格言“在罗马时,就像罗马人一样。” 在处理新文件时,请环顾四周以了解现有代码的结构。是否在私有变量和方法之前声明了所有公共变量和方法?方法是否按字母顺序排列?变量是否使用 firstServerName 中的“ camel case”或使用 first_server_name 中的“ snake case”?当你看到任何看起来可能是约定的内容时,请遵循该约定。在做出设计决策时,请问自己是否有可能在项目的其他地方做出了类似的决策;如果是这样,请找到一个现有示例,并在新代码中使用相同的方法。

不要更改现有约定。抵制“改善”现有公约的冲动。拥有一个“更好的主意”不足以引起矛盾。你的新想法可能确实更好,但是一致性胜于不一致的价值几乎总是大于一种方法胜过另一种方法的价值。在引入不一致的行为之前,请问自己两个问题。首先,你是否拥有大量的新信息来证明你的方法在建立旧约定时是不可用的?其次,新方法是否好得多,值得花时间更新所有旧用法?如果你的组织同意对两个问题的回答均为“是”,则继续进行升级;否则,请进行升级。完成后,应该没有旧约定的迹象。然而,你仍然冒着其他开发人员不了解新约定的风险,因此他们将来可能会重新引入旧方法。总体而言,重新考虑已建立的约定很少会很好地利用开发人员时间。

Taking it too far 走得太远

一致性不仅意味着相似的事情应该以相似的方式完成,而且不同的事情也应该以不同的方式完成。如果你对一致性过于热衷,并试图将不同的事物强制采用相同的方法,例如对确实不同的事物使用相同的变量名,或者对不适合该模式的任务使用现有的设计模式,那么会造成复杂性和混乱。一致性只有在开发人员确信“如果看起来像 x 时,它确实是 x”时才有好处。

一致性是投资心态的另一个例子。确保一致性的工作将需要一些额外的工作:确定约定,创建自动检查程序,寻找类似情况以模仿新代码,以及进行代码审查以教育团队。这项投资的回报是你的代码将更加明显。开发人员将能够更快,更准确地了解代码的行为,这将使他们能够以更少的错误来更快地工作。

代码应该是显而易见的Code Should be Obvious

晦涩难懂是此前(第二章)中描述的导致复杂性的两个主要原因之一。当有关系统的重要信息对于新开发人员而言并不明显时,就会发生模糊。解决晦涩问题的方法是以显而易见的方式编写代码。本章讨论使代码或多或少变得显而易见的一些因素。

如果代码很明显,则意味着某人可以不加思索地快速阅读该代码,并且他们对代码的行为或含义的最初猜测将是正确的。如果代码很明显,那么读者就不需要花费很多时间或精力来收集他们使用代码所需的所有信息。如果代码不明显,那么读者必须花费大量时间和精力来理解它。这不仅降低了它们的效率,而且还增加了误解和错误的可能性。明显的代码比不明显的代码需要更少的注释。

读者的想法是“显而易见”:注意到别人的代码不明显比发现自己的代码有问题要容易得多。因此,确定代码是否显而易见的最佳方法是通过代码审查。如果有人在阅读你的代码时说它并不明显,那么无论你看起来多么清晰,它也不是显而易见。通过尝试理解什么使代码变得不明显,你将学习如何在将来编写更好的代码。

Things that make code more obvious让代码更明显的事情

在前面的章节中已经讨论了使代码显而易见的两种最重要的技术。首先是选择好名字(第 14 章)。精确而有意义的名称可以阐明代码的行为,并减少对文档的需求。如果名称含糊不清或含糊不清,那么读者将通读代码以推论命名实体的含义;这既费时又容易出错。第二种技术是一致性(第 17 章)。如果总是以相似的方式完成相似的事情,那么读者可以识别出他们以前所见过的模式,并立即得出(安全)结论,而无需详细分析代码。

以下是使代码更明显的其他一些通用技术:

  • 明智地使用空白(Judicious use of white space)。代码格式化的方式会影响其理解的容易程度。考虑以下参数文档,其中空格已被压缩:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * ...
    * @param numThreads The number of threads that this manager should
    * spin up in order to manage ongoing connections. The MessageManager
    * spins up at least one thread for every open connection, so this
    * should be at least equal to the number of connections you expect
    * to be open at once. This should be a multiple of that number if
    * you expect to send a lot of messages in a short amount of time.
    * @param handler Used as a callback in order to handle incoming
    * messages on this MessageManager's open connections. See
    * {@code MessageHandler} and {@code handleMessage} for details.
    */

    很难看到一个参数的文档在哪里结束而下一个参数的文档在哪里开始。甚至不知道有多少个参数或它们的名称是什么。如果添加了一些空白,结构会突然变得清晰,文档也更容易浏览:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /**
    * @param numThreads
    * The number of threads that this manager should spin up in
    * order to manage ongoing connections. The MessageManager spins
    * up at least one thread for every open connection, so this
    * should be at least equal to the number of connections you
    * expect to be open at once. This should be a multiple of that
    * number if you expect to send a lot of messages in a short
    * amount of time.
    * @param handler
    * Used as a callback in order to handle incoming messages on
    * this MessageManager's open connections. See
    * {@code MessageHandler} and {@code handleMessage} for details.
    */

    空行也可用于分隔方法中的主要代码块,例如以下示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    void* Buffer::allocAux(size_t numBytes) {
    // Round up the length to a multiple of 8 bytes, to ensure alignment.
    uint32_t numBytes32 = (downCast<uint32_t>(numBytes) + 7) & ~0x7;
    assert(numBytes32 != 0);

    // If there is enough memory at firstAvailable, use that. Work down
    // from the top, because this memory is guaranteed to be aligned
    // (memory at the bottom may have been used for variable-size chunks).
    if (availableLength >= numBytes32) {
    availableLength -= numBytes32;
    return firstAvailable + availableLength;
    }

    // Next, see if there is extra space at the end of the last chunk.
    if (extraAppendBytes >= numBytes32) {
    extraAppendBytes -= numBytes32;
    return lastChunk->data + lastChunk->length + extraAppendBytes;
    }

    // Must create a new space allocation; allocate space within it.
    uint32_t allocatedLength;
    firstAvailable = getNewAllocation(numBytes32, &allocatedLength);
    availableLength = allocatedLength numBytes32;
    return firstAvailable + availableLength;
    }

    如果每个空白行之后的第一行是描述下一个代码块的注释,则此方法特别有效:空白行使注释更可见。
    语句中的空白有助于阐明语句的结构。比较以下两个语句,其中之一具有空格,而其中一个没有空格:

    1
    2
    3
    for(int pass=1;pass>=0&&!empty;pass--) {

    for (int pass = 1; pass >= 0 && !empty; pass--) {
  • 注释(Comments)。有时无法避免非显而易见的代码。发生这种情况时,重要的是使用注释来提供缺少的信息以进行补偿。要做到这一点,你必须使自己处于读者的位置,弄清楚什么可能会使他们感到困惑,以及哪些信息可以消除这种混乱。下一部分显示了一些示例。

Things that make code less obvious 使代码不那么明显的事情

有很多事情可以使代码变得不明显。本节提供了一些示例。其中某些功能(例如事件驱动的编程)在某些情况下很有用,因此你可能最终还是要使用它们。发生这种情况时,额外的文档可以帮助最大程度地减少读者的困惑。

事件驱动的编程。在事件驱动的编程中,应用程序对外部事件做出响应,例如网络数据包的到来或按下鼠标按钮。一个模块负责报告传入事件。应用程序的其他部分通过在事件发生时要求事件模块调用给定的函数或方法来注册对某些事件的兴趣。

事件驱动的编程使其很难遵循控制流程。永远不要直接调用事件处理函数。它们是由事件模块间接调用的,通常使用函数指针或接口。即使你在事件模块中找到了调用点,也仍然无法确定将调用哪个特定功能:这将取决于在运行时注册了哪些处理程序。因此,很难推理事件驱动的代码或说服自己相信它是可行的。

为了弥补这种模糊性,请为每个处理程序函数使用接口注释,以指示何时调用该函数,如以下示例所示:

1
2
3
4
5
6
7
/**
* This method is invoked in the dispatch thread by a transport if a
* transport-level error prevents an RPC from completing.
*/
void Transport::RpcNotifier::failed() {
...
}

如果无法通过快速阅读来理解代码的含义和行为,则它是一个危险标记。通常,这意味着有些重要的信息对于阅读代码的人来说并不能立即清除。

  • 通用容器(Generic containers.)。许多语言提供了用于将两个或多个项目组合到一个对象中的通用类,例如 Java 中的 Pair 或 C ++中的 std :: pair。这些类很诱人,因为它们使使用单个变量轻松传递多个对象变得容易。最常见的用途之一是从一个方法返回多个值,如以下 Java 示例所示:
1
return new Pair<Integer, Boolean>(currentTerm, false);

不幸的是,通用容器导致代码不清晰,因为分组后的元素的通用名称模糊了它们的含义。在上面的示例中,调用者必须使用 result.getKey()和 result.getValue()引用两个返回的值,而这两个值都不提供这些值的实际含义。

因此,最好不要使用通用容器。如果需要容器,请定义专门用于特定用途的新类或结构。然后,你可以为元素使用有意义的名称,并且可以在声明中提供其他文档,而对于常规容器而言,这是不可能的。

此示例说明了一条通用规则:软件应设计为易于阅读而不是易于编写。通用容器对于编写代码的人来说是很方便的,但是它们会使随后的所有读者感到困惑。对于编写代码的人来说,花一些额外的时间来定义特定的容器结构是更好的选择,以便使生成的代码更加明显。

不同类型的声明和分配。考虑以下 Java 示例:

1
2
3
private List<Message> incomingMessageList;
// ...
incomingMessageList = new ArrayList<Message>();

将该变量声明为 List,但实际值为 ArrayList。这段代码是合法的,因为 List 是 ArrayList 的超类,但是它会误导看到声明但不是实际分配的读者。实际类型可能会影响变量的使用方式(ArrayList 与 List 的其他子类相比,具有不同的性能和线程安全属性),因此最好将声明与分配匹配。

违反读者期望的代码。考虑以下代码,这是 Java 应用程序的主程序

1
2
3
4
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}

大多数应用程序在其主程序返回时退出,因此读者可能会认为这将在此处发生。但是,事实并非如此。RaftClient 的构造函数创建其他线程,即使应用程序的主线程完成,该线程仍可继续运行。应该在 RaftClient 构造函数的接口注释中记录此行为,但是该行为不够明显,因此值得在 main 末尾添加简短注释。该注释应指示该应用程序将继续在其他线程中执行。如果代码符合读者期望的惯例,那么它是最明显的。如果没有,那么记录该行为很重要,以免使读者感到困惑。

关于显而易见性的另一种思考方式是信息。如果代码不是显而易见的,则通常意味着存在有关读者所不具备的代码的重要信息:在 RaftClient 示例中,读者可能不知道 RaftClient 构造函数创建了新线程;在“配对”示例中,读者可能不知道 result.getKey()返回当前项的编号。为了使代码清晰可见,你必须确保读者始终拥有理解它们所需的信息。你可以通过三种方式执行此操作。最好的方法是使用抽象等设计技术并消除特殊情况,以减少所需的信息量。其次,你可以利用读者在其他情况下已经获得的信息(例如,通过遵循约定并符合期望),从而使读者不必为代码学习新的信息。第三,你可以使用诸如好名和战略注释之类的技术在代码中向他们提供重要信息。

Object-oriented programming and inheritance 面向对象的编程和继承

在过去的 30-40 年中,面向对象编程是软件开发中最重要的新思想之一。它引入了诸如类,继承,私有方法和实例变量之类的概念。如果仔细使用,这些机制可以帮助产生更好的软件设计。例如,私有方法和变量可用于确保信息隐藏:类外的任何代码都不能调用私有方法或访问私有变量,因此它们上没有任何外部依赖关系。

面向对象编程的关键要素之一是继承(inheritance)。继承有两种形式,它们对软件复杂性有不同的含义。继承的第一种形式是接口继承,其中父类定义一个或多个方法的签名,但不实现这些方法。每个子类都必须实现签名,但是不同的子类可以以不同的方式实现相同的方法。例如,该接口可能定义用于执行 I/O 的方法。一个子类可能对磁盘文件实现 I/O 操作,而另一个子类可能对网络套接字实现相同的操作。

接口继承通过出于多种目的重用同一接口,从而提供了针对复杂性的杠杆作用。它使解决一个问题(例如如何使用 I/O 接口读取和写入磁盘文件)中获得的知识可以用于解决其他问题(例如通过网络套接字进行通信)。关于深度的另一种思考方式是:接口的实现越不同,接口就越深入。为了使接口具有许多实现,它必须捕获所有基础实现的基本功能,同时避免实现之间的差异。这个概念是抽象的核心。

继承的第二种形式是实现继承(implementation inheritance)。以这种形式,父类不仅定义了一个或多个方法的签名,而且还定义了默认实现。子类可以选择继承方法的父类实现,也可以通过定义具有相同签名的新方法来覆盖它。如果没有实现继承,则可能需要在几个子类中复制相同的方法实现,这将在这些子类之间创建依赖关系(修改需要在方法的所有副本中复制)。因此,实现继承减少了随着系统的发展而需要修改的代码量。换句话说,它减少了此前(第 2 章)中描述的变化放大问题。

但是,实现继承会在父类及其每个子类之间创建依赖关系。父类和子类通常都访问父类中的类实例变量。这会导致继承层次结构中的类之间的信息泄漏,并且使得在不查看其他类的情况下很难修改层次结构中的一个类。例如,对父类进行更改的开发人员可能需要检查所有子类,以确保所做的更改不会破坏任何内容。同样,如果子类覆盖父类中的方法,则子类的开发人员可能需要检查父类中的实现。在最坏的情况下,程序员将需要完全了解父类下的整个类层次结构,以便对任何类进行更改。

因此,应谨慎使用实现继承。在使用实现继承之前,请考虑基于组合的方法是否可以提供相同的好处。例如,可以使用小型帮助程序类来实现共享功能。原始类可以从辅助类的功能构建,而不是从父类继承函数。

如果没有实现继承的可行选择,请尝试将父类管理的状态与子类管理的状态分开。一种方法是,某些实例变量完全由父类中的方法管理,子类仅以只读方式或通过父类中的其他方法使用它们。这适用于隐藏在类层次结构中的信息的概念,以减少依赖性。

尽管面向对象编程提供的机制可以帮助实现干净的设计,但是它们本身不能保证良好的设计。例如,如果类很浅,或者具有复杂的接口,或者允许外部访问其内部状态,那么它们仍将导致很高的复杂性。

Agile development 敏捷开发

敏捷开发是一种软件开发方法,它是在 1990 年代末期出现的,其思想涉及如何使软件开发更加轻量,灵活和增量。它是在 2001 年的一次从业者会议上正式定义的。敏捷开发主要是关于软件开发的过程(组织团队,管理进度表,单元测试的角色,与客户交互等),而不是软件设计。但是,它与本书中的某些设计原则有关。

敏捷开发中最重要的元素之一是开发应该是渐进的和迭代的概念。在敏捷方法中,软件系统是通过一系列迭代开发的,每个迭代都添加并评估了一些新功能。每个迭代都包括设计,测试和客户输入。通常,这类似于此处提倡的增量方法。如第 1 章所述,在项目开始时就不可能对复杂的系统进行充分的可视化以决定最佳设计。最终获得良好设计的最佳方法是逐步开发一个系统,其中每个增量都会添加一些新的抽象,并根据经验重构现有的抽象。这类似于敏捷开发方法。

敏捷开发的风险之一是它可能导致战术编程。敏捷开发倾向于使开发人员专注于功能,而不是抽象,它鼓励开发人员推迟设计决策,以便尽快生产可运行的软件。例如,一些敏捷的从业者认为,你不应该立即实施通用机制。实现一个最小的特殊用途机制,从此开始,并在以后知道需要时重构为更通用的东西。尽管这些论点在一定程度上是合理的,但它们反对投资方法,并鼓励采用更具战术性的编程风格。这可以导致复杂性的快速累积。

渐进式开发通常是一个好主意,但是渐进式开发应该是抽象的(abstractions),而不是功能的(features)。可以推迟对特定抽象的所有想法,直到功能需要它为止。一旦需要抽象,就要花一些时间进行简洁的设计。

Unit tests 单元测试

过去,开发人员很少编写测试。如果测试是由一个独立的 QA 团队编写的,那么它们就是由一个独立的 QA 团队编写的。然而,敏捷开发的原则之一是测试应该与开发紧密集成,程序员应该为他们自己的代码编写测试。这种做法现在已经很普遍了。测试通常分为两类: 单元测试和系统测试。单元测试是开发人员最常编写的测试。它们很小,而且重点突出:每个测试通常在单个方法中验证一小段代码。

单元测试可以独立运行,而不需要为系统设置生产环境。单元测试通常与测试覆盖工具一起运行,以确保应用程序中的每一行代码都经过了测试。每当开发人员编写新代码或修改现有代码时,他们都要负责更新单元测试以保持适当的测试覆盖率。

第二种测试包括系统测试(有时称为集成测试),这些测试可确保应用程序的不同部分都能正常协同工作。它们通常涉及在生产环境中运行整个应用程序。系统测试更有可能由独立的质量检查或测试小组编写。

测试,尤其是单元测试,在软件设计中起着重要作用,因为它们有助于重构。没有测试套件,对系统进行重大结构更改很危险。没有容易找到错误的方法,因此在部署新代码之前,很可能将无法检测到错误,因为在新代码中查找和修复它们的成本要高得多。结果,开发人员避免在没有良好测试套件的系统中进行重构。他们尝试将每个新功能或错误修复的代码更改次数减至最少,这意味着复杂性会累积,而设计错误不会得到纠正。

有了一套很好的测试,开发人员可以在重构时更有信心,因为测试套件将发现大多数引入的错误。这鼓励开发人员对系统进行结构改进,从而获得更好的设计。单元测试特别有价值:与系统测试相比,它们提供更高的代码覆盖率,因此它们更有可能发现任何错误。

例如,在开发 Tcl 脚本语言期间,我们决定通过用字节码编译器替换 Tcl 的解释器来提高性能。这是一个巨大的变化,几乎影响了核心 Tcl 引擎的每个部分。幸运的是,Tcl 有一个出色的单元测试套件,我们在新的字节码引擎上运行了该套件。现有测试在发现新引擎中的错误方面是如此有效,以至于在字节码编译器的 alpha 版本发布之后仅出现了一个错误。

Test-driven development 测试驱动的开发

测试驱动开发是一种软件开发方法,程序员可以在编写代码之前先编写单元测试。创建新类时,开发人员首先根据其预期行为为该类编写单元测试。没有测试通过,因为该类没有代码。然后,开发人员一次完成一个测试,编写足够的代码以使该测试通过。所有测试通过后,该类结束。

测试驱动开发的问题在于,它将注意力集中在使特定功能起作用,而不是寻找最佳设计上。这是一种纯净而简单的战术编程,具有所有缺点。测试驱动的开发过于增量:在任何时间点,很容易破解下一个功能以进行下一个测试通过。没有明显的时间进行设计,因此很容易陷入混乱。

开发单位应该是抽象的,而不是功能。一旦发现需要抽象,就不要随着时间的流逝而逐步创建抽象。一次设计所有功能(或至少足以提供一组合理全面的核心功能)。这样更有可能产生干净的设计,使各个部分很好地契合在一起。

首先编写测试的地方是修复错误。修复错误之前,请编写由于该错误而失败的单元测试。然后修复该错误,并确保现在可以通过单元测试。这是确保你已真正修复该错误的最佳方法。如果你在编写测试之前就已修复了该错误,则新的单元测试很可能实际上不会触发该错误,在这种情况下,它不会告诉你是否确实修复了该问题。

Design patterns 设计模式

设计模式是解决特定类型问题(例如迭代器或观察器)的常用方法。设计模式的概念在 Gamma,Helm,Johnson 和 Vlissides 的《设计模式:可重用的面向对象软件的元素》一书中得到了普及,现在设计模式已广泛用于面向对象的软件开发中。

设计模式代表了设计的替代方法:与其从头设计新的机制,不如应用一种众所周知的设计模式。在大多数情况下,这是件好事:出现设计模式是因为它们解决了常见的问题,并且因为它们被普遍同意提供干净的解决方案。如果设计模式在特定情况下运作良好,那么你可能很难想出另一种更好的方法。

设计模式的最大风险是过度使用。使用现有的设计模式并不能完全解决所有问题。当自定义方法更加简洁时,请勿尝试将问题强加到设计模式中。使用设计模式并不能自动改善软件系统。只有在设计模式合适的情况下才这样做。与软件设计中的许多想法一样,设计模式良好的概念并不一定意味着更多的设计模式会更好。

Getters and setters Getter 和 Setters

在 Java 编程社区中,getter 和 setter 方法是一种流行的设计模式。一个 getter 和一个 setter 与一个类的实例变量相关联。它们具有类似 getFoo 和 setFoo 的名称,其中 Foo 是变量的名称。getter 方法返回变量的当前值,setter 方法修改该值。

由于实例变量可以公开,因此不一定必须使用 getter 和 setter 方法。getter 和 setter 的论点是,它们允许在获取和设置时执行其他功能,例如在变量更改时更新相关值,通知更改的侦听器或对值实施约束。即使最初不需要这些功能,也可以稍后添加它们而无需更改界面。

尽管如果必须公开实例变量,则可以使用 getter 和 setter 方法,但最好不要首先公开实例变量。暴露的实例变量意味着类的实现的一部分在外部是可见的,这违反了信息隐藏的思想,并增加了类接口的复杂性。Getter 和 Setter 是浅层方法(通常只有一行),因此它们在不提供太多功能的情况下为类的接口增加了混乱。最好避免使用 getter 和 setter(或任何暴露的实现数据)。

建立设计模式的风险之一是,开发人员认为该模式是好的,并尝试尽可能多地使用它。这导致 Java 中的 getter 和 setter 的过度使用。

每当你遇到有关新软件开发范例的提案时,就必须从复杂性的角度对其进行挑战:该提案确实有助于最大程度地降低大型软件系统的复杂性吗?从表面上看,许多建议听起来不错,但是如果你深入研究,你会发现其中一些会使复杂性恶化,而不是更好。

设计性能Designing for Performance

到目前为止,关于软件设计的讨论都集中在复杂性上。目标是使软件尽可能简单易懂。但是,如果你在需要快速的系统上工作,该怎么办?性能方面的考虑应如何影响设计过程?本章讨论如何在不牺牲简洁设计的情况下实现高性能。最重要的想法仍然是简单性:简单性不仅可以改善系统的设计,而且通常可以使系统更快。

How to think about performance 如何考虑性能

要解决的第一个问题是“你在正常的开发过程中应该为性能多少担心?” 如果你尝试优化每条语句以获得最大速度,则它将减慢开发速度并产生许多不必要的复杂性。此外,许多“优化”实际上对性能没有帮助。另一方面,如果你完全忽略了性能问题,则很容易导致遍及整个代码的大量效率低下。结果系统很容易比所需的速度慢 5–10 倍。在这种“千刀砍死”的情况下,以后很难再回来提高性能了,因为没有单一的改进会产生很大的影响。

最好的方法是介于这两种极端之间,在这种极端情况下,你可以使用性能的基本知识来选择“自然高效”但又干净又简单的设计替代方案。关键是要了解哪些操作根本是昂贵的。以下是一些今天相对昂贵的操作示例:

  • 网络通信:即使在数据中心内,往返消息交换也可能花费 10–50 µs,这是数以万计的指令时间。广域往返可能需要 10 到 100 毫秒。
  • I/O 到辅助存储:磁盘 I/O 操作通常需要 5 到 10 毫秒,这是数百万条指令时间。闪存存储需要 10–100 µs。新出现的非易失性存储器的速度可能高达 1 µs,但这仍约为 2000 条指令时间。
  • 动态内存分配(C 语言中的 malloc,C ++或 Java 中的新增功能)通常涉及分配,释放和垃圾回收的大量开销。
  • 高速缓存未命中:将数据从 DRAM 提取到片上处理器高速缓存中需要数百条指令时间;在许多程序中,整体性能取决于缓存未命中和计算成本。

了解哪些东西最昂贵的最好方法是运行微基准测试(小型程序,这些程序单独测量单个操作的成本)。在 RAMCloud 项目中,我们创建了一个简单的程序,该程序提供了微基准测试的框架。创建该框架花了几天时间,但是该框架使在五到十分钟内添加新的微基准成为可能。这使我们积累了几十个微基准。我们既可以使用它们来了解 RAMCloud 中使用的现有库的性能,也可以衡量为 RAMCloud 编写的新类的性能。

一旦对什么是昂贵和什么便宜有了一般的认识,就可以使用该信息尽可能地选择便宜的业务。在许多情况下,更有效的方法将与较慢的方法一样简单。例如,当存储将使用键值查找的大量对象时,可以使用哈希表或有序映射。两者都通常在库包中提供,并且都简单易用。但是,哈希表可以轻松地快 5-10 倍。因此,除非需要映射提供的排序属性,否则应始终使用哈希表。

作为另一个示例,请考虑使用诸如 C 或 C ++之类的语言分配结构数组。有两种方法可以执行此操作。一种方法是让数组保留指向结构的指针,在这种情况下,你必须首先为数组分配空间,然后为每个单独的结构分配空间。将结构存储在数组本身中效率要高得多,因此你只为所有内容分配一个大块。

如果提高效率的唯一方法是增加复杂性,那么选择就更加困难。如果更高效的设计仅增加了少量复杂性,并且复杂性是隐藏的,因此它不影响任何接口,那么它可能是值得的(但要注意:复杂性是递增的)。如果更快的设计增加了很多实现复杂性,或者导致更复杂的接口,那么最好是从更简单的方法开始,然后在性能出现问题时进行优化。但是,如果你有明确的证据表明性能在特定情况下很重要,那么你最好立即实施更快的方法。

“在 RAMCloud 项目中,我们的总体目标之一是为客户端计算机通过数据中心网络访问存储系统提供尽可能低的延迟。结果,我们决定使用特殊的硬件进行联网,从而使 RAMCloud 绕过内核并直接与网络接口控制器进行通信以发送和接收数据包。即使增加了复杂性,我们还是做出了这个决定,因为我们从先前的测量中知道,基于内核的网络太慢了,无法满足我们的需求。在其余的 RAMCloud 系统中,我们能够进行简单设计。解决这个大问题“对”使其他事情变得更加容易。”

通常,较简单的代码往往比复杂的代码运行更快。如果你定义了特殊情况和例外,则无需代码即可检查这些情况,并且系统运行速度更快。深层类比浅层类更有效,因为它们为每个方法调用完成了更多工作。浅类会导致更多的层交叉,并且每个层交叉都会增加开销。

Measure before modifying 修改前的度量

但是,即使你如上所述进行设计,也请假设你的系统仍然太慢。根据你对慢速运动的直觉,急于着手开始进行性能调整。不要这样!程序员对性能的直觉是不可靠的。即使对于有经验的开发人员也是如此。如果你开始根据直觉进行更改,则会浪费时间在实际上无法提高性能的事情上,并且可能会使系统变得更加复杂。

进行任何更改之前,请测量系统的现有行为。这有两个目的。首先,这些测量将确定性能调整将产生最大影响的地方。仅仅测量顶级系统性能是不够的。这可能会告诉你系统速度太慢,但不会告诉你原因。你需要进行更深入的衡量,以详细确定影响整体绩效的因素;目标是确定系统当前花费大量时间的少量非常具体的地方,以及你有改进想法的地方。测量的第二个目的是提供基线,以便你可以在进行更改后重新测量性能,以确保性能得到实际改善。如果这些更改并未在效果上带来可衡量的变化,然后将其退出(除非它们使系统更简单)。除非能够显着提高速度,否则保持复杂性毫无意义。

Design around the critical path 围绕关键路径进行设计

在这一点上,我们假设你已经仔细分析了性能,并确定了一段缓慢的代码来影响整个系统的性能。改善其性能的最佳方法是进行“根本”更改,例如引入缓存,或使用其他算法方法(例如,平衡树与列表)。我们决定绕过内核进行 RAMCloud 中的网络通信的决定是一个基本修补程序的示例。如果你可以确定基本修复程序,则可以使用前面各章中讨论的设计技术来实施它。

不幸的是,有时会出现一些根本无法解决的情况。这将我们带到本章的核心问题,即如何重新设计现有代码,使其运行更快。这应该是你的不得已的方法,并且不应该经常发生,但是在某些情况下它可能会带来很大的不同。关键思想是围绕关键路径设计代码。

首先,问自己在通常情况下执行所需任务必须执行的最少代码量是多少。忽略任何现有的代码结构。相反,想象一下你正在编写一个仅实现关键路径的新方法,这是在最常见的情况下必须执行的最少代码量。当前的代码可能充满特殊情况。在此练习中,请忽略它们。当前的代码可能会在关键路径上通过多个方法调用。想象一下,你可以将所有相关代码放在一个方法中。当前代码还可以使用各种变量和数据结构。仅考虑关键路径所需的数据,并假定最适合关键路径的任何数据结构。例如,将多个变量合并为一个值可能很有意义。假设你可以完全重新设计系统,以最大程度地减少必须为关键路径执行的代码。我们将此代码称为“理想”。

理想的代码可能会与你现有的类结构冲突,并且可能不切实际,但它提供了一个很好的目标:这代表了代码可能是最简单,最快的。下一步是寻找一种新设计,使其尽可能接近理想状态,同时又要保持干净的结构。你可以应用本书前面各章中的所有设计思想,但要保持(最好)保持理想代码的附加约束。你可能需要在理想情况下添加一些额外的代码,以允许使用简洁的抽象。例如,如果代码涉及哈希表查找,则可以向通用哈希表类引入额外的方法调用。以我的经验,几乎总是可以找到干净简洁的设计,但非常接近理想。

在此过程中发生的最重要的事情之一是从关键路径中除去特殊情况。当代码运行缓慢时,通常是因为它必须处理各种情况,并且代码经过结构化以简化所有不同情况的处理。每个特殊情况都以额外的条件语句和/或方法调用的形式向关键路径添加了一些代码。这些添加中的每一个都会使代码变慢。重新设计性能时,请尝试减少必须检查的特殊情况的数量。理想情况下,开头应该有一个 if 语句,该语句可以通过一个测试检测所有特殊情况。在正常情况下,只需要进行一项测试,之后就可以执行关键路径,而对于特殊情况则无需进行其他测试。如果初始测试失败(这意味着发生了特殊情况),则代码可以分支到关键路径之外的单独位置以进行处理。对于特殊情况,性能并不是那么重要,因此你可以为简化而不是性能来构造特殊情况的代码。

干净的设计和高性能是兼容的。重写 Buffer 类可将其性能提高 2 倍,同时简化其设计并将代码大小减少 20%。复杂的代码通常会很慢,因为它会执行多余或多余的工作。另一方面,如果你编写干净,简单的代码,则系统可能会足够快,因此你一开始就不必担心性能。在少数需要优化性能的情况下,关键再次是简单性:找到对性能最重要的关键路径并使它们尽可能简单。

结论 Conclusion

复杂性。处理复杂性是软件设计中最重要的挑战。这是使系统难以构建和维护的原因,并且通常也使它们变慢。在本书的整个过程中,我试图描述导致复杂性的根本原因,例如依赖性和模糊性。我已经讨论了可以帮助你识别不必要的复杂性的危险标记,例如信息泄漏,不必要的错误情况或名称过于笼统。我已经提出了一些通用的思想,可以用来创建更简单的软件系统,例如,努力研究更深和更通用的类,定义不存在的错误以及将接口文档与实现文档分离。最后,我讨论了产生简单设计所需的投资思路。

所有这些建议的缺点是它们会在项目的早期阶段创建额外的工作。此外,如果你不习惯于思考设计问题,那么当你学习良好的设计技巧时,你甚至会放慢脚步。如果对你而言唯一重要的事情就是尽快使当前代码工作,那么思考设计就好像是在费劲工作,而这实际上妨碍了你实现真正的目标。

另一方面,如果良好的设计对你来说是重要的目标,那么本书中的思想应使编程更有趣。设计是一个令人着迷的难题:如何用最简单的结构解决特定问题?探索不同的方法很有趣,找到一种既简单又强大的解决方案是一种很好的感觉。干净,简单和明显的设计是一件美丽的事情。

此外,你对优质设计的投资将很快获得回报。在项目开始时仔细定义的模块将为你节省时间,因为你一遍又一遍地重复使用它们。你六个月前编写的清晰文档将为你节省返回代码添加新功能的时间。花在磨练设计技能上的时间也将有所回报:随着技能和经验的增长,你会发现可以越来越快地制作出好的设计。一旦知道了什么,一个好的设计实际上并不会比一个简单的设计花费更多的时间。

成为优秀设计师的好处是,你可以在设计阶段花费大部分时间,这很有趣。可怜的设计师花费大量时间在复杂而脆弱的代码中寻找错误。如果提高设计技能,不仅可以更快地生产出更高质量的软件,而且软件开发过程也将变得更加愉快。

Summary of Design Principles 设计原则摘要

  • 复杂性是逐步增加的:您必须关注一些小东西(Complexity is incremental: you have to sweat the small stuff)
  • 工作代码还不够(Working code isn’t enough)
  • 持续进行少量投资以改善系统设计(Make continual small investments to improve system design)
  • 模块应较深(Modules should be deep)
  • 接口的设计应尽可能简化最常见的用法(Interfaces should be designed to make the most common usage as simple as possible)
  • 一个模块具有一个简单的接口比一个简单的实现更重要(It’s more important for a module to have a simple interface than a simple implementation)
  • 通用模块更深入(General-purpose modules are deeper)
  • 通用和专用代码分开(Separate general-purpose and special-purpose code)
  • 不同的层应具有不同的抽象(Different layers should have different abstractions)
  • 降低复杂度(Pull complexity downward)
  • 定义不存在的错误(和特殊情况)(Define errors (and special cases) out of existence)
  • 设计两次(Design it twice)
  • 注释应描述代码中不明显的内容(Comments should describe things that are not obvious from the code)
  • 软件的设计应易于阅读而不是易于编写(Software should be designed for ease of reading, not ease of writing)
  • 软件开发的增量应该是抽象而不是功能(The increments of software development should be abstractions, not features)